chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Test artifacts
|
||||
tests/captured_messages/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Local development files
|
||||
.env
|
||||
*.log
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,49 @@
|
||||
# DevUI Package (agent-framework-devui)
|
||||
|
||||
Interactive developer UI for testing and debugging agents and workflows.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`serve()`** - Launch the DevUI server
|
||||
- **`DevServer`** - The FastAPI-based development server
|
||||
- **`register_cleanup()`** - Register cleanup hooks for entities
|
||||
- **`CheckpointConversationManager`** - Manages conversation checkpoints
|
||||
|
||||
## Models
|
||||
|
||||
- **`AgentFrameworkRequest`** - Request model for agent invocations
|
||||
- **`OpenAIResponse`** / **`OpenAIError`** - OpenAI-compatible response models
|
||||
- **`DiscoveryResponse`** / **`EntityInfo`** - Entity discovery models
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework.devui import serve
|
||||
|
||||
agent = Agent(...)
|
||||
serve(entities=[agent], port=8080, auto_open=True)
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Run with auto-discovery
|
||||
devui ./agents
|
||||
|
||||
# Run with specific entities
|
||||
devui --entities my_agent.py
|
||||
```
|
||||
|
||||
## Security Posture
|
||||
|
||||
DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default.
|
||||
Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require
|
||||
`DEVUI_AUTH_TOKEN` or `--auth-token`.
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.devui import serve, register_cleanup
|
||||
# or directly:
|
||||
from agent_framework_devui import serve
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,397 @@
|
||||
# DevUI - A Sample App for Running Agents and Workflows
|
||||
|
||||
A lightweight, standalone sample app interface for running entities (agents/workflows) in the Microsoft Agent Framework supporting **directory-based discovery**, **in-memory entity registration**, and **sample entity gallery**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> DevUI is a **sample app** to help you get started with the Agent Framework. It is **not** intended for production use. For production, or for features beyond what is provided in this sample app, it is recommended that you build your own custom interface and API server using the Agent Framework SDK.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install agent-framework-devui --pre
|
||||
```
|
||||
|
||||
You can also launch it programmatically
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.devui import serve
|
||||
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get weather for a location."""
|
||||
return f"Weather in {location}: 72°F and sunny"
|
||||
|
||||
# Create your agent
|
||||
agent = Agent(
|
||||
name="WeatherAgent",
|
||||
client=OpenAIChatClient(),
|
||||
tools=[get_weather]
|
||||
)
|
||||
|
||||
# Launch debug UI - that's it!
|
||||
serve(entities=[agent], auto_open=True)
|
||||
# → Opens browser to http://localhost:8080
|
||||
```
|
||||
|
||||
In addition, if you have agents/workflows defined in a specific directory structure (see below), you can launch DevUI from the _cli_ to discover and run them.
|
||||
|
||||
```bash
|
||||
|
||||
# Launch web UI + API server
|
||||
devui ./agents --port 8080
|
||||
# → Web UI: http://localhost:8080
|
||||
# → API: http://localhost:8080/v1/*
|
||||
```
|
||||
|
||||
DevUI is auth-enabled by default. Localhost starts with a generated development token logged at startup; pass it as
|
||||
`Authorization: Bearer <token>` for direct API calls.
|
||||
|
||||
When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly.
|
||||
|
||||
## Using MCP Tools
|
||||
|
||||
**Important:** Don't use `async with` context managers when creating agents with MCP tools for DevUI - connections will close before execution.
|
||||
|
||||
```python
|
||||
# ✅ Correct - DevUI handles cleanup automatically
|
||||
mcp_tool = MCPStreamableHTTPTool(url="http://localhost:8011/mcp", client=client)
|
||||
agent = Agent(tools=mcp_tool)
|
||||
serve(entities=[agent])
|
||||
```
|
||||
|
||||
MCP tools use lazy initialization and connect automatically on first use. DevUI attempts to clean up connections on shutdown
|
||||
|
||||
## Resource Cleanup
|
||||
|
||||
Register cleanup hooks to properly close credentials and resources on shutdown:
|
||||
|
||||
```python
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatCompletionClient
|
||||
from agent_framework_devui import register_cleanup, serve
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
client = OpenAIChatCompletionClient()
|
||||
agent = Agent(name="MyAgent", client=client)
|
||||
|
||||
# Register cleanup hook - credential will be closed on shutdown
|
||||
register_cleanup(agent, credential.close)
|
||||
serve(entities=[agent])
|
||||
```
|
||||
|
||||
Works with multiple resources and file-based discovery. See tests for more examples.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
For your agents to be discovered by the DevUI, they must be organized in a directory structure like below. Each agent/workflow must have an `__init__.py` that exports the required variable (`agent` or `workflow`).
|
||||
|
||||
**Note**: `.env` files are optional but will be automatically loaded if present in the agent/workflow directory or parent entities directory. Use them to store API keys, configuration variables, and other environment-specific settings.
|
||||
|
||||
```
|
||||
agents/
|
||||
├── weather_agent/
|
||||
│ ├── __init__.py # Must export: agent = Agent(...)
|
||||
│ ├── agent.py
|
||||
│ └── .env # Optional: API keys, config vars
|
||||
├── my_workflow/
|
||||
│ ├── __init__.py # Must export: workflow = WorkflowBuilder(start_executor=...)...
|
||||
│ ├── workflow.py
|
||||
│ └── .env # Optional: environment variables
|
||||
└── .env # Optional: shared environment variables
|
||||
```
|
||||
|
||||
### Importing from External Modules
|
||||
|
||||
If your agents import tools or utilities from sibling directories (e.g., `from tools.helpers import my_tool`), you must set `PYTHONPATH` to include the parent directory:
|
||||
|
||||
```bash
|
||||
# Project structure:
|
||||
# backend/
|
||||
# ├── agents/
|
||||
# │ └── my_agent/
|
||||
# │ └── agent.py # contains: from tools.helpers import my_tool
|
||||
# └── tools/
|
||||
# └── helpers.py
|
||||
|
||||
# Run from project root with PYTHONPATH
|
||||
cd backend
|
||||
PYTHONPATH=. devui ./agents --port 8080
|
||||
```
|
||||
|
||||
Without `PYTHONPATH`, Python cannot find modules in sibling directories and DevUI will report an import error.
|
||||
|
||||
## Viewing Telemetry (Otel Traces) in DevUI
|
||||
|
||||
Agent Framework emits OpenTelemetry (Otel) traces for various operations. You can view these traces in DevUI by enabling instrumentation when starting the server.
|
||||
|
||||
```bash
|
||||
devui ./agents --instrumentation
|
||||
```
|
||||
|
||||
## OpenAI-Compatible API
|
||||
|
||||
For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the entity_id in metadata**, and set streaming to `True` as needed.
|
||||
|
||||
```bash
|
||||
# Simple - use your entity name as the entity_id in metadata
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- << 'EOF'
|
||||
{
|
||||
"metadata": {"entity_id": "weather_agent"},
|
||||
"input": "Hello world"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Or use the OpenAI Python SDK:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:8080/v1",
|
||||
api_key="<devui-token>"
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
metadata={"entity_id": "weather_agent"}, # Your agent/workflow name
|
||||
input="What's the weather in Seattle?"
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
print(response.output[0].content[0].text)
|
||||
# Supports streaming with stream=True
|
||||
```
|
||||
|
||||
### Multi-turn Conversations
|
||||
|
||||
Use the standard OpenAI `conversation` parameter for multi-turn conversations:
|
||||
|
||||
```python
|
||||
# Create a conversation
|
||||
conversation = client.conversations.create(
|
||||
metadata={"agent_id": "weather_agent"}
|
||||
)
|
||||
|
||||
# Use it across multiple turns
|
||||
response1 = client.responses.create(
|
||||
metadata={"entity_id": "weather_agent"},
|
||||
input="What's the weather in Seattle?",
|
||||
conversation=conversation.id
|
||||
)
|
||||
|
||||
response2 = client.responses.create(
|
||||
metadata={"entity_id": "weather_agent"},
|
||||
input="How about tomorrow?",
|
||||
conversation=conversation.id # Continues the conversation!
|
||||
)
|
||||
```
|
||||
|
||||
**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests.
|
||||
|
||||
### OpenAI Proxy Mode
|
||||
|
||||
DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly through the interface without creating custom agents. Enable via Settings → OpenAI Proxy tab.
|
||||
|
||||
**How it works:** The UI sends requests to the DevUI backend (with `X-Proxy-Backend: openai` header), which then proxies them to OpenAI's Responses API (and Conversations API for multi-turn chats). This proxy approach keeps your `OPENAI_API_KEY` secure on the server—never exposed in the browser or client-side code.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "X-Proxy-Backend: openai" \
|
||||
-d '{"model": "gpt-4.1-mini", "input": "Hello"}'
|
||||
```
|
||||
|
||||
**Note:** Requires `OPENAI_API_KEY` environment variable configured on the backend.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```bash
|
||||
devui [directory] [options]
|
||||
|
||||
Options:
|
||||
--port, -p Port (default: 8080)
|
||||
--host Host (default: 127.0.0.1; non-loopback hosts require auth)
|
||||
--headless API only, no UI
|
||||
--no-open Don't automatically open browser
|
||||
--instrumentation Enable OpenTelemetry instrumentation
|
||||
--reload Enable auto-reload
|
||||
--mode developer|user (default: developer)
|
||||
--no-auth Disable auth for loopback-only local development
|
||||
--auth-token Custom authentication token (required for non-loopback hosts unless DEVUI_AUTH_TOKEN is set)
|
||||
```
|
||||
|
||||
### UI Modes
|
||||
|
||||
- **developer** (default): Full access - debug panel, entity details, hot reload, deployment
|
||||
- **user**: Simplified UI with restricted APIs - only chat and conversation management
|
||||
|
||||
```bash
|
||||
# Development
|
||||
devui ./agents
|
||||
|
||||
# Local-only no-auth development
|
||||
devui ./agents --no-auth
|
||||
```
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
## API Mapping
|
||||
|
||||
Given that DevUI offers an OpenAI Responses API, it internally maps messages and events from Agent Framework to OpenAI Responses API events (in `_mapper.py`). For transparency, this mapping is shown below:
|
||||
|
||||
| OpenAI Event/Type | Agent Framework Content | Status |
|
||||
| ------------------------------------------------------------ | --------------------------------- | -------- |
|
||||
| | **Lifecycle Events** | |
|
||||
| `response.created` + `response.in_progress` | `AgentStartedEvent` | OpenAI |
|
||||
| `response.completed` | `AgentCompletedEvent` | OpenAI |
|
||||
| `response.failed` | `AgentFailedEvent` | OpenAI |
|
||||
| `response.created` + `response.in_progress` | `WorkflowEvent (type='started')` | OpenAI |
|
||||
| `response.completed` | `WorkflowEvent (type='status')` | OpenAI |
|
||||
| `response.failed` | `WorkflowEvent (type='failed')` | OpenAI |
|
||||
| | **Content Types** | |
|
||||
| `response.content_part.added` + `response.output_text.delta` | `TextContent` | OpenAI |
|
||||
| `response.reasoning_text.delta` | `TextReasoningContent` | OpenAI |
|
||||
| `response.output_item.added` | `FunctionCallContent` (initial) | OpenAI |
|
||||
| `response.function_call_arguments.delta` | `FunctionCallContent` (args) | OpenAI |
|
||||
| `response.function_result.complete` | `FunctionResultContent` | DevUI |
|
||||
| `response.function_approval.requested` | `FunctionApprovalRequestContent` | DevUI |
|
||||
| `response.function_approval.responded` | `FunctionApprovalResponseContent` | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputImage) | `DataContent` (images) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputFile) | `DataContent` (files) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputData) | `DataContent` (other) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputImage/File) | `UriContent` (images/files) | DevUI |
|
||||
| `error` | `ErrorContent` | OpenAI |
|
||||
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
|
||||
| | **Workflow Events** | |
|
||||
| `response.output_item.added` (ExecutorActionItem)* | `WorkflowEvent (type='executor_invoked')` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem)* | `WorkflowEvent (type='executor_completed')` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem with error)* | `WorkflowEvent (type='executor_failed')` | OpenAI |
|
||||
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowEvent (type='output')` | OpenAI |
|
||||
| `response.workflow_event.complete` | `WorkflowEvent` (other types) | DevUI |
|
||||
| `response.trace.complete` | `WorkflowEvent (type='status')` | DevUI |
|
||||
| `response.trace.complete` | `WorkflowEvent (type='warning')` | DevUI |
|
||||
| | **Trace Content** | |
|
||||
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
|
||||
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
|
||||
| `response.trace.complete` | `HostedFileContent` | DevUI |
|
||||
| `response.trace.complete` | `HostedVectorStoreContent` | DevUI |
|
||||
|
||||
\*Uses standard OpenAI event structure but carries DevUI-specific `ExecutorActionItem` payload
|
||||
|
||||
- **OpenAI** = Standard OpenAI Responses API event types
|
||||
- **DevUI** = Custom event types specific to Agent Framework (e.g., workflows, traces, function approvals)
|
||||
|
||||
### OpenAI Responses API Compliance
|
||||
|
||||
DevUI follows the OpenAI Responses API specification for maximum compatibility:
|
||||
|
||||
**OpenAI Standard Event Types Used:**
|
||||
|
||||
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls, images, files, data)
|
||||
- `ResponseOutputItemDoneEvent` - Output item completion notifications
|
||||
- `Response.usage` - Token usage (in final response, not streamed)
|
||||
|
||||
**Custom DevUI Extensions:**
|
||||
|
||||
- `response.output_item.added` with custom item types:
|
||||
- `ResponseOutputImage` - Agent-generated images (inline display)
|
||||
- `ResponseOutputFile` - Agent-generated files (inline display)
|
||||
- `ResponseOutputData` - Agent-generated structured data (inline display)
|
||||
- `response.function_approval.requested` - Function approval requests (for interactive approval workflows)
|
||||
- `response.function_approval.responded` - Function approval responses (user approval/rejection)
|
||||
- `response.function_result.complete` - Server-side function execution results
|
||||
- `response.workflow_event.complete` - Agent Framework workflow events
|
||||
- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores)
|
||||
|
||||
These custom extensions are clearly namespaced and can be safely ignored by standard OpenAI clients. Note that DevUI also uses standard OpenAI events with custom payloads (e.g., `ExecutorActionItem` within `response.output_item.added`).
|
||||
|
||||
### Entity Management
|
||||
|
||||
- `GET /v1/entities` - List discovered agents/workflows
|
||||
- `GET /v1/entities/{entity_id}/info` - Get detailed entity information
|
||||
- `POST /v1/entities/{entity_id}/reload` - Hot reload entity (for development)
|
||||
|
||||
### Execution (OpenAI Responses API)
|
||||
|
||||
- `POST /v1/responses` - Execute agent/workflow (streaming or sync)
|
||||
|
||||
### Conversations (OpenAI Standard)
|
||||
|
||||
- `POST /v1/conversations` - Create conversation
|
||||
- `GET /v1/conversations/{id}` - Get conversation
|
||||
- `POST /v1/conversations/{id}` - Update conversation metadata
|
||||
- `DELETE /v1/conversations/{id}` - Delete conversation
|
||||
- `GET /v1/conversations?agent_id={id}` - List conversations _(DevUI extension)_
|
||||
- `POST /v1/conversations/{id}/items` - Add items to conversation
|
||||
- `GET /v1/conversations/{id}/items` - List conversation items
|
||||
- `GET /v1/conversations/{id}/items/{item_id}` - Get conversation item
|
||||
|
||||
### Health
|
||||
|
||||
- `GET /health` - Health check
|
||||
|
||||
## Security
|
||||
|
||||
DevUI is designed as a **sample application for local development** and is not intended for production use. For
|
||||
production, or for features beyond this sample app, build a custom interface and API server using the Agent Framework SDK.
|
||||
|
||||
Auth is enabled by default. Unauthenticated mode is allowed only when DevUI is bound to `localhost` or `127.0.0.1`.
|
||||
Network-reachable binds such as `0.0.0.0`, LAN IPs, and hostnames require Bearer token authentication with an explicit
|
||||
token.
|
||||
|
||||
**For shared development hosts:**
|
||||
|
||||
```bash
|
||||
# Set a token explicitly before binding beyond loopback
|
||||
DEVUI_AUTH_TOKEN="<secure-dev-token>" devui ./agents --mode user --host 0.0.0.0
|
||||
|
||||
# Or pass the token on the command line
|
||||
devui ./agents --mode user --host 0.0.0.0 --auth-token "<secure-dev-token>"
|
||||
```
|
||||
|
||||
Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup.
|
||||
|
||||
**Security features:**
|
||||
|
||||
- User mode restricts developer-facing APIs
|
||||
- Bearer token authentication is enabled by default
|
||||
- Unauthenticated mode is loopback-only (`localhost` / `127.0.0.1`)
|
||||
- Non-loopback binds require `DEVUI_AUTH_TOKEN` or `--auth-token`
|
||||
- Only loads entities from local directories or in-memory registration
|
||||
- No remote code execution capabilities
|
||||
- Binds to localhost (127.0.0.1) by default
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Do not use DevUI as a production deployment surface
|
||||
- Use `--mode user` plus `DEVUI_AUTH_TOKEN` or `--auth-token` for shared development hosts
|
||||
- Review all agent/workflow code before running
|
||||
- Only load entities from trusted sources
|
||||
- Use `.env` files for sensitive credentials (never commit them)
|
||||
|
||||
## Implementation
|
||||
|
||||
- **Discovery**: `agent_framework_devui/_discovery.py`
|
||||
- **Execution**: `agent_framework_devui/_executor.py`
|
||||
- **Message Mapping**: `agent_framework_devui/_mapper.py`
|
||||
- **Conversations**: `agent_framework_devui/_conversations.py`
|
||||
- **API Server**: `agent_framework_devui/_server.py`
|
||||
- **CLI**: `agent_framework_devui/_cli.py`
|
||||
|
||||
## Examples
|
||||
|
||||
See working implementations in `python/samples/02-agents/devui/`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework DevUI - Debug interface with OpenAI compatible API server."""
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ._conversations import CheckpointConversationManager
|
||||
from ._server import DevServer
|
||||
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
|
||||
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cleanup registry (before serve() is called)
|
||||
_cleanup_registry: dict[int, list[Callable[[], Any]]] = {}
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
|
||||
def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
|
||||
"""Register cleanup hook(s) for an entity.
|
||||
|
||||
Cleanup hooks execute during DevUI server shutdown, before entity
|
||||
clients are closed. Supports both synchronous and asynchronous callables.
|
||||
|
||||
Args:
|
||||
entity: Agent, workflow, or other entity object
|
||||
*hooks: One or more cleanup callables (sync or async)
|
||||
|
||||
Raises:
|
||||
ValueError: If no hooks provided
|
||||
|
||||
Examples:
|
||||
Single cleanup hook:
|
||||
>>> from agent_framework.devui import serve, register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = Agent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> serve(entities=[agent])
|
||||
|
||||
Multiple cleanup hooks:
|
||||
>>> register_cleanup(agent, credential.close, session.close, db_pool.close)
|
||||
|
||||
Works with file-based discovery:
|
||||
>>> # In agents/my_agent/agent.py
|
||||
>>> from agent_framework.devui import register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = Agent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> # Run: devui ./agents
|
||||
"""
|
||||
if not hooks:
|
||||
raise ValueError("At least one cleanup hook required")
|
||||
|
||||
# Use id() to track entity identity (works across modules)
|
||||
entity_id = id(entity)
|
||||
|
||||
if entity_id not in _cleanup_registry:
|
||||
_cleanup_registry[entity_id] = []
|
||||
|
||||
_cleanup_registry[entity_id].extend(hooks)
|
||||
|
||||
logger.debug(
|
||||
f"Registered {len(hooks)} cleanup hook(s) for {type(entity).__name__} "
|
||||
f"(id: {entity_id}, total: {len(_cleanup_registry[entity_id])})"
|
||||
)
|
||||
|
||||
|
||||
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]: # type: ignore[reportUnusedFunction]
|
||||
"""Get cleanup hooks registered for an entity (internal use).
|
||||
|
||||
Args:
|
||||
entity: Entity object to get hooks for
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks registered for the entity
|
||||
"""
|
||||
entity_id = id(entity)
|
||||
return _cleanup_registry.get(entity_id, [])
|
||||
|
||||
|
||||
def serve(
|
||||
entities: list[Any] | None = None,
|
||||
entities_dir: str | None = None,
|
||||
port: int = 8080,
|
||||
host: str = "127.0.0.1",
|
||||
auto_open: bool = False,
|
||||
cors_origins: list[str] | None = None,
|
||||
ui_enabled: bool = True,
|
||||
instrumentation_enabled: bool = False,
|
||||
mode: str = "developer",
|
||||
auth_enabled: bool = True,
|
||||
auth_token: str | None = None,
|
||||
) -> None:
|
||||
"""Launch Agent Framework DevUI with simple API.
|
||||
|
||||
Args:
|
||||
entities: List of entities for in-memory registration (IDs auto-generated)
|
||||
entities_dir: Directory to scan for entities
|
||||
port: Port to run server on
|
||||
host: Host to bind server to
|
||||
auto_open: Whether to automatically open browser
|
||||
cors_origins: List of allowed CORS origins
|
||||
ui_enabled: Whether to enable the UI
|
||||
instrumentation_enabled: Whether to enable OpenTelemetry instrumentation
|
||||
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
|
||||
auth_enabled: Whether to enable Bearer token authentication
|
||||
auth_token: Custom authentication token (auto-generated if not provided with auth_enabled=True)
|
||||
"""
|
||||
import re
|
||||
|
||||
import uvicorn
|
||||
|
||||
# Validate host parameter early for security
|
||||
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
|
||||
raise ValueError(f"Invalid host: {host}. Must be localhost, IP address, or valid hostname")
|
||||
|
||||
# Validate port parameter
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
|
||||
|
||||
# Enable instrumentation if requested
|
||||
if instrumentation_enabled:
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
|
||||
enable_instrumentation(enable_sensitive_data=True)
|
||||
logger.info("Enabled Agent Framework instrumentation with sensitive data")
|
||||
|
||||
# Create server with direct parameters
|
||||
server = DevServer(
|
||||
entities_dir=entities_dir,
|
||||
port=port,
|
||||
host=host,
|
||||
cors_origins=cors_origins,
|
||||
ui_enabled=ui_enabled,
|
||||
mode=mode,
|
||||
auth_enabled=auth_enabled,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
|
||||
# Register in-memory entities if provided
|
||||
if entities:
|
||||
logger.info(f"Registering {len(entities)} in-memory entities")
|
||||
# Store entities for later registration during server startup
|
||||
server.set_pending_entities(entities)
|
||||
|
||||
app = server.get_app()
|
||||
|
||||
if auto_open:
|
||||
|
||||
def open_browser() -> None:
|
||||
import http.client
|
||||
import re
|
||||
import time
|
||||
|
||||
# Validate host and port for security
|
||||
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
|
||||
logger.warning(f"Invalid host for auto-open: {host}")
|
||||
return
|
||||
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
logger.warning(f"Invalid port for auto-open: {port}")
|
||||
return
|
||||
|
||||
# Wait for server to be ready by checking health endpoint
|
||||
browser_url = f"http://{host}:{port}"
|
||||
|
||||
for _ in range(30): # 15 second timeout (30 * 0.5s)
|
||||
try:
|
||||
# Use http.client for safe connection handling (standard library)
|
||||
conn = http.client.HTTPConnection(host, port, timeout=1)
|
||||
try:
|
||||
conn.request("GET", "/health")
|
||||
response = conn.getresponse()
|
||||
if response.status == 200:
|
||||
webbrowser.open(browser_url)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
except (http.client.HTTPException, OSError, TimeoutError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# Fallback: open browser anyway after timeout
|
||||
webbrowser.open(browser_url)
|
||||
|
||||
import threading
|
||||
|
||||
threading.Thread(target=open_browser, daemon=True).start()
|
||||
|
||||
logger.info(f"Starting Agent Framework DevUI on {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for devui command."""
|
||||
from ._cli import main as cli_main
|
||||
|
||||
cli_main()
|
||||
|
||||
|
||||
# Export main public API
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"CheckpointConversationManager",
|
||||
"DevServer",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"EnvVarRequirement",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"ResponseStreamEvent",
|
||||
"main",
|
||||
"register_cleanup",
|
||||
"serve",
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Command line interface for Agent Framework DevUI."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""Configure logging for the server."""
|
||||
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=getattr(logging, level.upper()), format=log_format, datefmt="%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def create_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create the command line argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="devui",
|
||||
description="Launch Agent Framework DevUI - Debug interface with OpenAI compatible API",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
devui # Scan current directory
|
||||
devui ./agents # Scan specific directory
|
||||
devui --port 8000 # Custom port
|
||||
devui --headless # API only, no UI
|
||||
devui --instrumentation # Enable OpenTelemetry instrumentation
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"directory", nargs="?", default=".", help="Directory to scan for entities (default: current directory)"
|
||||
)
|
||||
|
||||
parser.add_argument("--port", "-p", type=int, default=8080, help="Port to run server on (default: 8080)")
|
||||
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind server to (default: 127.0.0.1)")
|
||||
|
||||
parser.add_argument("--no-open", action="store_true", help="Don't automatically open browser")
|
||||
|
||||
parser.add_argument("--headless", action="store_true", help="Run without UI (API only)")
|
||||
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
default="INFO",
|
||||
help="Logging level (default: INFO)",
|
||||
)
|
||||
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
|
||||
|
||||
parser.add_argument("--instrumentation", action="store_true", help="Enable OpenTelemetry instrumentation")
|
||||
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["developer", "user"],
|
||||
default=None,
|
||||
help="Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)",
|
||||
)
|
||||
|
||||
# Add --dev/--no-dev as a convenient alternative to --mode
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="dev_mode",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Enable developer mode (shorthand for --mode developer)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-dev",
|
||||
dest="dev_mode",
|
||||
action="store_false",
|
||||
help="Disable developer mode (shorthand for --mode user)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-auth",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
type=str,
|
||||
help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.",
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get the package version."""
|
||||
try:
|
||||
from . import __version__
|
||||
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def validate_directory(directory: str) -> str:
|
||||
"""Validate and normalize the entities directory."""
|
||||
if not directory:
|
||||
directory = "."
|
||||
|
||||
abs_dir = os.path.abspath(directory)
|
||||
|
||||
if not os.path.exists(abs_dir):
|
||||
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(abs_dir):
|
||||
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
return abs_dir
|
||||
|
||||
|
||||
def print_startup_info(
|
||||
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
|
||||
) -> None:
|
||||
"""Print startup information."""
|
||||
print("Agent Framework DevUI") # noqa: T201
|
||||
print("=" * 50) # noqa: T201
|
||||
print(f"Entities directory: {entities_dir}") # noqa: T201
|
||||
print(f"Server URL: http://{host}:{port}") # noqa: T201
|
||||
print(f"UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
|
||||
print(f"Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
|
||||
|
||||
# Display auth token if authentication is enabled
|
||||
if auth_token:
|
||||
print("Authentication: Enabled") # noqa: T201
|
||||
print(f"Auth token: {auth_token}") # noqa: T201
|
||||
print("💡 Use this token in Authorization: Bearer <token> header") # noqa: T201
|
||||
|
||||
print("=" * 50) # noqa: T201
|
||||
print("Scanning for entities...") # noqa: T201
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
parser = create_cli_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
setup_logging(args.log_level)
|
||||
|
||||
# Validate directory
|
||||
entities_dir = validate_directory(args.directory)
|
||||
|
||||
# Extract parameters directly from args
|
||||
ui_enabled = not args.headless
|
||||
|
||||
# Determine mode from --mode or --dev/--no-dev flags
|
||||
if args.dev_mode is not None:
|
||||
# --dev or --no-dev was specified
|
||||
mode = "developer" if args.dev_mode else "user"
|
||||
elif args.mode is not None:
|
||||
# --mode was specified
|
||||
mode = args.mode
|
||||
else:
|
||||
# Default to developer mode
|
||||
mode = "developer"
|
||||
|
||||
# Print startup info (don't show token - serve() will handle it)
|
||||
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload, None)
|
||||
|
||||
# Import and start server
|
||||
try:
|
||||
from . import serve
|
||||
|
||||
serve(
|
||||
entities_dir=entities_dir,
|
||||
port=args.port,
|
||||
host=args.host,
|
||||
auto_open=not args.no_open,
|
||||
ui_enabled=ui_enabled,
|
||||
instrumentation_enabled=args.instrumentation,
|
||||
mode=mode,
|
||||
auth_enabled=not args.no_auth,
|
||||
auth_token=args.auth_token, # Pass through explicit token only
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down Agent Framework DevUI...") # noqa: T201
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start server")
|
||||
print(f"Error: {e}", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,717 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Conversation storage abstraction for OpenAI Conversations API.
|
||||
|
||||
This module provides a clean abstraction layer for managing conversations
|
||||
with in-memory message storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import MutableSequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import AgentSession, Message
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.conversations.message import Content as OpenAIContent
|
||||
from openai.types.conversations.message import Message as OpenAIMessage
|
||||
from openai.types.conversations.text_content import TextContent
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputFile,
|
||||
ResponseInputImage,
|
||||
)
|
||||
|
||||
# Type alias for OpenAI Message role literals
|
||||
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
|
||||
|
||||
# Checkpoint item type constants
|
||||
CONVERSATION_ITEM_TYPE_CHECKPOINT = "checkpoint"
|
||||
CONVERSATION_TYPE_CHECKPOINT_CONTAINER = "checkpoint_container"
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""Abstract base class for conversation storage.
|
||||
|
||||
Provides OpenAI Conversations API interface while managing
|
||||
message storage internally.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation.
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
|
||||
conversation_id: Optional conversation ID (if None, generates one)
|
||||
|
||||
Returns:
|
||||
Conversation object with generated or provided ID
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Conversation object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
metadata: New metadata dict
|
||||
|
||||
Returns:
|
||||
Updated Conversation object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
ConversationDeletedResource object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
items: List of conversation items to add
|
||||
|
||||
Returns:
|
||||
List of added ConversationItem objects
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
limit: Maximum number of items to return
|
||||
after: Cursor for pagination (item_id)
|
||||
order: Sort order ("asc" or "desc")
|
||||
|
||||
Returns:
|
||||
Tuple of (items list, has_more boolean)
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get a specific conversation item by ID.
|
||||
|
||||
Supports checkpoint items - will load full checkpoint state from storage.
|
||||
For checkpoints, the full state is included in metadata.full_checkpoint.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
ConversationItem or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self, conversation_id: str) -> AgentSession | None:
|
||||
"""Get AgentSession for agent execution.
|
||||
|
||||
This is the critical method that allows the executor to get the
|
||||
AgentSession for running agents with conversation context.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
AgentSession object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id).
|
||||
|
||||
Args:
|
||||
metadata_filter: Metadata key-value pairs to match
|
||||
|
||||
Returns:
|
||||
List of matching Conversation objects
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
|
||||
"""Add a trace event to the conversation for context inspection.
|
||||
|
||||
Traces capture execution metadata like token usage, timing, and LLM context
|
||||
that is useful for debugging.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
trace_event: Trace event data (from ResponseTraceEvent.data)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all trace events for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
List of trace event dicts, or empty list if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class InMemoryConversationStore(ConversationStore):
|
||||
"""In-memory conversation storage.
|
||||
|
||||
This implementation stores conversations in memory with their
|
||||
underlying message lists and AgentSession instances for execution.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize in-memory conversation storage.
|
||||
|
||||
Storage structure maps conversation IDs to conversation data including
|
||||
messages, metadata, and cached ConversationItems.
|
||||
"""
|
||||
self._conversations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}}
|
||||
self._item_index: dict[str, dict[str, ConversationItem]] = {}
|
||||
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation with message storage and checkpoint storage."""
|
||||
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Create message list for internal storage and AgentSession for execution
|
||||
messages: list[Message] = []
|
||||
session = AgentSession(session_id=conv_id)
|
||||
|
||||
# Create session-scoped checkpoint storage (one per conversation)
|
||||
checkpoint_storage = InMemoryCheckpointStorage()
|
||||
|
||||
self._conversations[conv_id] = {
|
||||
"id": conv_id,
|
||||
"messages": messages,
|
||||
"session": session,
|
||||
"checkpoint_storage": checkpoint_storage,
|
||||
"metadata": metadata or {},
|
||||
"created_at": created_at,
|
||||
"items": [],
|
||||
"traces": [], # Trace events for context inspection (token usage, timing, etc.)
|
||||
}
|
||||
|
||||
# Initialize item index for this conversation
|
||||
self._item_index[conv_id] = {}
|
||||
|
||||
return Conversation(id=conv_id, object="conversation", created_at=created_at, metadata=metadata)
|
||||
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
return None
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_data.get("metadata"),
|
||||
)
|
||||
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
conv_data["metadata"] = metadata
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation."""
|
||||
if conversation_id not in self._conversations:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
del self._conversations[conversation_id]
|
||||
# Cleanup item index
|
||||
self._item_index.pop(conversation_id, None)
|
||||
|
||||
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
|
||||
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
stored_messages: list[Message] = conv_data["messages"]
|
||||
|
||||
# Convert items to Messages and add to storage
|
||||
chat_messages: list[Message] = []
|
||||
for item in items:
|
||||
# Simple conversion - assume text content for now
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", [])
|
||||
first_content = cast(
|
||||
dict[str, Any],
|
||||
content[0] if content and isinstance(content, list) and isinstance(content[0], dict) else {},
|
||||
)
|
||||
text_obj = first_content.get("text", "")
|
||||
text = text_obj if isinstance(text_obj, str) else str(text_obj)
|
||||
|
||||
chat_msg = Message(role=role, contents=[text])
|
||||
chat_messages.append(chat_msg)
|
||||
|
||||
# Add messages to internal storage
|
||||
stored_messages.extend(chat_messages)
|
||||
|
||||
# Create Message objects (ConversationItem is a Union - use concrete Message type)
|
||||
conv_items: list[ConversationItem] = []
|
||||
for msg in chat_messages:
|
||||
item_id = f"item_{uuid.uuid4().hex}"
|
||||
|
||||
# Convert Message contents to OpenAI TextContent format
|
||||
message_content: MutableSequence[OpenAIContent] = []
|
||||
for content_item in msg.contents:
|
||||
if content_item.type == "text":
|
||||
# Extract text from TextContent object
|
||||
message_content.append(TextContent(type="text", text=content_item.text or ""))
|
||||
|
||||
# Create Message object (concrete type from ConversationItem union)
|
||||
message = OpenAIMessage(
|
||||
id=item_id,
|
||||
type="message", # Required discriminator for union
|
||||
role=cast(MessageRole, msg.role), # Safe: Agent Framework roles match OpenAI roles,
|
||||
content=message_content,
|
||||
status="completed", # Required field
|
||||
)
|
||||
conv_items.append(message)
|
||||
|
||||
# Cache items
|
||||
conv_data["items"].extend(conv_items)
|
||||
|
||||
# Update item index for O(1) lookup
|
||||
if conversation_id not in self._item_index:
|
||||
self._item_index[conversation_id] = {}
|
||||
|
||||
for conv_item in conv_items:
|
||||
if conv_item.id: # Guard against None
|
||||
self._item_index[conversation_id][conv_item.id] = conv_item
|
||||
|
||||
return conv_items
|
||||
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items.
|
||||
|
||||
Converts stored Messages to proper OpenAI ConversationItem types:
|
||||
- Messages with text/images/files → Message
|
||||
- Function calls → ResponseFunctionToolCallItem
|
||||
- Function results → ResponseFunctionToolCallOutputItem
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
stored_messages: list[Message] = conv_data["messages"]
|
||||
|
||||
# Convert stored messages to ConversationItem types
|
||||
items: list[ConversationItem] = []
|
||||
af_messages = stored_messages
|
||||
|
||||
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
|
||||
for i, msg in enumerate(af_messages):
|
||||
item_id = f"item_{i}"
|
||||
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
|
||||
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
|
||||
|
||||
# Process each content item in the message
|
||||
# A single Message may produce multiple ConversationItems
|
||||
# (e.g., a message with both text and a function call)
|
||||
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
|
||||
function_calls: list[ResponseFunctionToolCallItem] = []
|
||||
function_results: list[ResponseFunctionToolCallOutputItem] = []
|
||||
|
||||
for content in msg.contents:
|
||||
content_type = getattr(content, "type", None)
|
||||
|
||||
if content_type == "text":
|
||||
# Text content for Message
|
||||
text_value = getattr(content, "text", "")
|
||||
message_contents.append(TextContent(type="text", text=text_value))
|
||||
|
||||
elif content_type == "data":
|
||||
# Data content (images, files, PDFs)
|
||||
uri = getattr(content, "uri", "")
|
||||
media_type = getattr(content, "media_type", None)
|
||||
|
||||
if media_type and media_type.startswith("image/"):
|
||||
# Convert to ResponseInputImage
|
||||
message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto"))
|
||||
else:
|
||||
# Convert to ResponseInputFile
|
||||
# Extract filename from URI if possible
|
||||
filename = None
|
||||
if media_type == "application/pdf":
|
||||
filename = "document.pdf"
|
||||
|
||||
message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename))
|
||||
|
||||
elif content_type == "function_call":
|
||||
# Function call - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
name = getattr(content, "name", "")
|
||||
arguments = getattr(content, "arguments", "")
|
||||
|
||||
if call_id and name:
|
||||
function_calls.append(
|
||||
ResponseFunctionToolCallItem(
|
||||
id=f"{item_id}_call_{call_id}",
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
type="function_call",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
elif content_type == "function_result":
|
||||
# Function result - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
# Output is stored in the 'result' field of FunctionResultContent
|
||||
result_value = getattr(content, "result", None)
|
||||
# Convert result to string (it could be dict, list, or other types)
|
||||
if result_value is None:
|
||||
output = ""
|
||||
elif isinstance(result_value, str):
|
||||
output = result_value
|
||||
else:
|
||||
import json
|
||||
|
||||
try:
|
||||
output = json.dumps(result_value)
|
||||
except (TypeError, ValueError):
|
||||
output = str(result_value)
|
||||
|
||||
if call_id:
|
||||
function_results.append(
|
||||
ResponseFunctionToolCallOutputItem(
|
||||
id=f"{item_id}_result_{call_id}",
|
||||
call_id=call_id,
|
||||
output=output,
|
||||
type="function_call_output",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Create ConversationItems based on what we found
|
||||
# If message has text/images/files, create a Message item
|
||||
if message_contents:
|
||||
message = OpenAIMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role=role,
|
||||
content=message_contents, # type: ignore
|
||||
status="completed",
|
||||
)
|
||||
items.append(message)
|
||||
|
||||
# Add function call items
|
||||
items.extend(function_calls)
|
||||
|
||||
# Add function result items
|
||||
items.extend(function_results)
|
||||
|
||||
# Include checkpoints from checkpoint storage as conversation items
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if checkpoint_storage:
|
||||
# Get all checkpoints for this conversation
|
||||
checkpoints = self._list_all_checkpoints(checkpoint_storage)
|
||||
for checkpoint in checkpoints:
|
||||
# Create a conversation item for each checkpoint with summary metadata
|
||||
# Full checkpoint state is NOT included here (too large for list view)
|
||||
# Use get_item() to retrieve full checkpoint details
|
||||
# Calculate approximate size of checkpoint
|
||||
import json
|
||||
|
||||
checkpoint_json = json.dumps(checkpoint.to_dict())
|
||||
checkpoint_size = len(checkpoint_json.encode("utf-8"))
|
||||
|
||||
checkpoint_item = {
|
||||
"id": f"checkpoint_{checkpoint.checkpoint_id}",
|
||||
"type": "checkpoint",
|
||||
"checkpoint_id": checkpoint.checkpoint_id,
|
||||
# Keep workflow_id for backward compatibility with existing UI payloads.
|
||||
"workflow_id": checkpoint.workflow_name,
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
"timestamp": checkpoint.timestamp,
|
||||
"status": "completed",
|
||||
"metadata": {
|
||||
# Summary metrics for list view
|
||||
"iteration_count": checkpoint.iteration_count,
|
||||
"pending_hil_count": len(checkpoint.pending_request_info_events),
|
||||
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
|
||||
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
|
||||
"size_bytes": checkpoint_size,
|
||||
"version": checkpoint.version,
|
||||
"graph_signature_hash": checkpoint.graph_signature_hash,
|
||||
},
|
||||
}
|
||||
items.append(cast(ConversationItem, checkpoint_item))
|
||||
|
||||
# Apply pagination
|
||||
if order == "desc":
|
||||
items = items[::-1]
|
||||
|
||||
start_idx = 0
|
||||
if after:
|
||||
# Find the index after the cursor
|
||||
for i, item in enumerate(items):
|
||||
if item.id == after:
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
paginated_items = items[start_idx : start_idx + limit]
|
||||
has_more = len(items) > start_idx + limit
|
||||
|
||||
return paginated_items, has_more
|
||||
|
||||
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get a specific conversation item by ID.
|
||||
|
||||
Supports checkpoint items - will load full checkpoint state from storage.
|
||||
For checkpoints, the full state is included in metadata.full_checkpoint.
|
||||
"""
|
||||
# First check item index for messages, function calls, etc. (O(1) lookup)
|
||||
conv_items = self._item_index.get(conversation_id, {})
|
||||
item = conv_items.get(item_id)
|
||||
if item:
|
||||
return item
|
||||
|
||||
# If not found and ID is a checkpoint, load from checkpoint storage
|
||||
if item_id.startswith("checkpoint_"):
|
||||
checkpoint_id = item_id[len("checkpoint_") :] # Remove "checkpoint_" prefix
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
return None
|
||||
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if not checkpoint_storage:
|
||||
return None
|
||||
|
||||
# Load full checkpoint from storage
|
||||
try:
|
||||
checkpoint = await checkpoint_storage.load(checkpoint_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Calculate size of checkpoint
|
||||
import json
|
||||
|
||||
checkpoint_json = json.dumps(checkpoint.to_dict())
|
||||
checkpoint_size = len(checkpoint_json.encode("utf-8"))
|
||||
|
||||
# Build checkpoint item with FULL state in metadata
|
||||
checkpoint_item = {
|
||||
"id": item_id,
|
||||
"type": "checkpoint",
|
||||
"checkpoint_id": checkpoint.checkpoint_id,
|
||||
# Keep workflow_id for backward compatibility with existing UI payloads.
|
||||
"workflow_id": checkpoint.workflow_name,
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
"timestamp": checkpoint.timestamp,
|
||||
"status": "completed",
|
||||
"metadata": {
|
||||
# Summary metrics (same as list view)
|
||||
"iteration_count": checkpoint.iteration_count,
|
||||
"pending_hil_count": len(checkpoint.pending_request_info_events),
|
||||
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
|
||||
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
|
||||
"size_bytes": checkpoint_size,
|
||||
"version": checkpoint.version,
|
||||
"graph_signature_hash": checkpoint.graph_signature_hash,
|
||||
# 🔥 FULL checkpoint state (lazy loaded)
|
||||
"full_checkpoint": checkpoint.to_dict(),
|
||||
},
|
||||
}
|
||||
|
||||
return cast(ConversationItem, checkpoint_item)
|
||||
|
||||
return None
|
||||
|
||||
def get_session(self, conversation_id: str) -> AgentSession | None:
|
||||
"""Get AgentSession for execution - CRITICAL for agent.run()."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
return conv_data["session"] if conv_data else None
|
||||
|
||||
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
|
||||
"""Add a trace event to the conversation for context inspection.
|
||||
|
||||
Traces capture execution metadata like token usage, timing, and LLM context
|
||||
that is useful for debugging.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
trace_event: Trace event data (from ResponseTraceEvent.data)
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if conv_data:
|
||||
traces = conv_data.get("traces", [])
|
||||
traces.append(trace_event)
|
||||
conv_data["traces"] = traces
|
||||
|
||||
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all trace events for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
List of trace event dicts, or empty list if not found
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
return conv_data.get("traces", []) if conv_data else []
|
||||
|
||||
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id)."""
|
||||
results: list[Conversation] = []
|
||||
for conv_data in self._conversations.values():
|
||||
conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original
|
||||
|
||||
# Check if all filter items match
|
||||
if all(conv_meta.get(k) == v for k, v in metadata_filter.items()):
|
||||
# Enrich workflow sessions with checkpoint summary
|
||||
if conv_meta.get("type") == "workflow_session":
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if checkpoint_storage:
|
||||
checkpoints = self._list_all_checkpoints(checkpoint_storage)
|
||||
latest = max(checkpoints, key=lambda cp: cp.timestamp) if checkpoints else None
|
||||
conv_meta["checkpoint_summary"] = {
|
||||
"count": len(checkpoints),
|
||||
"latest_iteration": latest.iteration_count if latest else 0,
|
||||
"has_pending_hil": len(latest.pending_request_info_events) > 0 if latest else False,
|
||||
"pending_hil_count": len(latest.pending_request_info_events) if latest else 0,
|
||||
}
|
||||
|
||||
results.append(
|
||||
Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_meta,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by created_at descending (most recent first)
|
||||
results.sort(key=lambda c: c.created_at, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _list_all_checkpoints(checkpoint_storage: Any) -> list[WorkflowCheckpoint]:
|
||||
"""Return all checkpoints from a conversation-scoped storage instance.
|
||||
|
||||
DevUI uses one checkpoint storage per conversation. Core storage APIs now
|
||||
require workflow_name filters, so we gather directly from in-memory storage
|
||||
internals to provide conversation-wide listing for UI views.
|
||||
"""
|
||||
checkpoint_map = getattr(checkpoint_storage, "_checkpoints", None)
|
||||
if isinstance(checkpoint_map, dict):
|
||||
return list(cast(dict[str, WorkflowCheckpoint], checkpoint_map).values())
|
||||
return []
|
||||
|
||||
|
||||
class CheckpointConversationManager:
|
||||
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
|
||||
|
||||
Simplified architecture: Each conversation has its own InMemoryCheckpointStorage
|
||||
stored in conv_data["checkpoint_storage"]. This manager just retrieves it.
|
||||
Session isolation comes from each conversation having a separate storage instance.
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
# Runtime validation since we need specific implementation details
|
||||
if not isinstance(conversation_store, InMemoryConversationStore):
|
||||
raise TypeError("CheckpointConversationManager currently requires InMemoryConversationStore")
|
||||
self._store: InMemoryConversationStore = conversation_store
|
||||
# Keep public reference for backward compatibility with tests
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
def get_checkpoint_storage(self, conversation_id: str) -> InMemoryCheckpointStorage:
|
||||
"""Get the checkpoint storage for a specific conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
InMemoryCheckpointStorage instance for this conversation
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
# Access internal conversations dict (we know it's InMemoryConversationStore)
|
||||
conversations_dict = cast(dict[str, dict[str, Any]], getattr(self._store, "_conversations", {}))
|
||||
conv_data = conversations_dict.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
checkpoint_storage = conv_data["checkpoint_storage"]
|
||||
if not isinstance(checkpoint_storage, InMemoryCheckpointStorage):
|
||||
raise TypeError(f"Expected InMemoryCheckpointStorage but got {type(checkpoint_storage)}")
|
||||
return checkpoint_storage
|
||||
@@ -0,0 +1,604 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Container Apps deployment manager for DevUI entities."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeploymentManager:
|
||||
"""Manages entity deployments to Azure Container Apps."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize deployment manager."""
|
||||
self._deployments: dict[str, Deployment] = {}
|
||||
|
||||
async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGenerator[DeploymentEvent, None]:
|
||||
"""Deploy entity to Azure Container Apps with streaming events.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
|
||||
Yields:
|
||||
DeploymentEvent objects for real-time progress updates
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met or deployment fails
|
||||
"""
|
||||
deployment_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
# Step 1: Validate prerequisites
|
||||
yield DeploymentEvent(
|
||||
type="deploy.validating",
|
||||
message="Checking prerequisites (Azure CLI, Docker, authentication)...",
|
||||
)
|
||||
|
||||
await self._validate_prerequisites()
|
||||
|
||||
# Step 2: Generate Dockerfile
|
||||
yield DeploymentEvent(
|
||||
type="deploy.dockerfile",
|
||||
message="Generating Dockerfile with authentication enabled...",
|
||||
)
|
||||
|
||||
_ = await self._generate_dockerfile(entity_path, config)
|
||||
|
||||
# Step 3: Generate auth token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.token",
|
||||
message="Generating secure authentication token...",
|
||||
)
|
||||
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Step 4: Discover existing Container App Environment
|
||||
yield DeploymentEvent(
|
||||
type="deploy.environment",
|
||||
message="Checking for existing Container App Environment...",
|
||||
)
|
||||
|
||||
# Step 5: Build and deploy with Azure CLI
|
||||
yield DeploymentEvent(
|
||||
type="deploy.building",
|
||||
message=f"Deploying to Azure Container Apps ({config.region})...",
|
||||
)
|
||||
|
||||
# Create a queue for streaming events from subprocess
|
||||
event_queue: asyncio.Queue[DeploymentEvent] = asyncio.Queue()
|
||||
|
||||
# Run deployment in background task with event queue
|
||||
deployment_task = asyncio.create_task(self._deploy_to_azure(config, entity_path, auth_token, event_queue))
|
||||
|
||||
# Stream events from queue while deployment runs
|
||||
while True:
|
||||
try:
|
||||
# Check if deployment task is done
|
||||
if deployment_task.done():
|
||||
# Get the result or exception
|
||||
deployment_url = await deployment_task
|
||||
break
|
||||
|
||||
# Get event from queue with short timeout
|
||||
yield await asyncio.wait_for(event_queue.get(), timeout=0.1)
|
||||
except asyncio.TimeoutError:
|
||||
# No event in queue, continue waiting
|
||||
continue
|
||||
|
||||
# Step 5: Store deployment record
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url=deployment_url,
|
||||
status="deployed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
# Step 6: Success - return URL and token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.completed",
|
||||
message=f"Deployment successful! URL: {deployment_url}",
|
||||
url=deployment_url,
|
||||
auth_token=auth_token, # Shown once to user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Deployment failed: {e!s}"
|
||||
logger.exception(error_msg)
|
||||
|
||||
# Store failed deployment
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url="",
|
||||
status="failed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
error=str(e),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
yield DeploymentEvent(
|
||||
type="deploy.failed",
|
||||
message=error_msg,
|
||||
)
|
||||
|
||||
async def _validate_prerequisites(self) -> None:
|
||||
"""Validate that Azure CLI, Docker, authentication, and resource providers are available.
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met
|
||||
"""
|
||||
# Check Azure CLI
|
||||
az_check = await asyncio.create_subprocess_exec(
|
||||
"az", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await az_check.communicate()
|
||||
if az_check.returncode != 0:
|
||||
raise ValueError(
|
||||
"Azure CLI not found. Install from: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
||||
)
|
||||
|
||||
# Check Docker
|
||||
docker_check = await asyncio.create_subprocess_exec(
|
||||
"docker", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await docker_check.communicate()
|
||||
if docker_check.returncode != 0:
|
||||
raise ValueError("Docker not found. Install from: https://www.docker.com/get-started")
|
||||
|
||||
# Check Azure authentication
|
||||
az_account_check = await asyncio.create_subprocess_exec(
|
||||
"az", "account", "show", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await az_account_check.communicate()
|
||||
if az_account_check.returncode != 0:
|
||||
raise ValueError("Not authenticated with Azure. Run: az login")
|
||||
|
||||
# Check required resource providers are registered
|
||||
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
|
||||
unregistered_providers: list[str] = []
|
||||
|
||||
# Get list of registered providers
|
||||
provider_check = await asyncio.create_subprocess_exec(
|
||||
"az",
|
||||
"provider",
|
||||
"list",
|
||||
"--query",
|
||||
"[?registrationState=='Registered'].namespace",
|
||||
"--output",
|
||||
"json",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _stderr = await provider_check.communicate()
|
||||
|
||||
if provider_check.returncode == 0:
|
||||
import json
|
||||
|
||||
try:
|
||||
registered_raw = json.loads(stdout.decode())
|
||||
registered: list[str] = []
|
||||
if isinstance(registered_raw, list):
|
||||
for item_obj in cast(list[object], registered_raw):
|
||||
if isinstance(item_obj, str):
|
||||
registered.append(item_obj)
|
||||
for provider in required_providers:
|
||||
if provider not in registered:
|
||||
unregistered_providers.append(provider)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Could not parse provider list, skipping provider validation")
|
||||
else:
|
||||
logger.warning("Could not check provider registration status")
|
||||
|
||||
if unregistered_providers:
|
||||
commands = [f"az provider register -n {p} --wait" for p in unregistered_providers]
|
||||
raise ValueError(
|
||||
f"Required Azure resource providers not registered: {', '.join(unregistered_providers)}\n\n"
|
||||
f"Register them by running:\n" + "\n".join(commands) + "\n\n"
|
||||
"This is a one-time setup per Azure subscription."
|
||||
)
|
||||
|
||||
logger.info("All prerequisites validated successfully")
|
||||
|
||||
async def _generate_dockerfile(self, entity_path: Path, config: DeploymentConfig) -> Path:
|
||||
"""Generate Dockerfile for entity deployment.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory
|
||||
config: Deployment configuration
|
||||
|
||||
Returns:
|
||||
Path to generated Dockerfile
|
||||
"""
|
||||
# Validate ui_mode
|
||||
if config.ui_mode not in ["user", "developer"]:
|
||||
raise ValueError(f"Invalid ui_mode: {config.ui_mode}. Must be 'user' or 'developer'.")
|
||||
|
||||
# Check if requirements.txt exists in the entity directory
|
||||
has_requirements = (entity_path / "requirements.txt").exists()
|
||||
|
||||
requirements_section = ""
|
||||
if has_requirements:
|
||||
logger.info(f"Found requirements.txt in {entity_path}, will include in Dockerfile")
|
||||
requirements_section = """# Install entity dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
"""
|
||||
else:
|
||||
logger.info(f"No requirements.txt found in {entity_path}, skipping dependency installation")
|
||||
|
||||
dockerfile_content = f"""FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
{requirements_section}# Install DevUI from PyPI
|
||||
RUN pip install agent-framework-devui --pre
|
||||
|
||||
# Copy entity code
|
||||
COPY . /app/entity/
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Launch DevUI. Auth is enabled by default and reads the token from the environment.
|
||||
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080"]
|
||||
"""
|
||||
|
||||
dockerfile_path = entity_path / "Dockerfile"
|
||||
|
||||
# Warn if Dockerfile already exists
|
||||
if dockerfile_path.exists():
|
||||
logger.warning(f"Dockerfile already exists at {dockerfile_path}, overwriting...")
|
||||
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
logger.info(f"Generated Dockerfile at {dockerfile_path}")
|
||||
|
||||
return dockerfile_path
|
||||
|
||||
async def _discover_container_app_environment(self, resource_group: str, region: str) -> str | None:
|
||||
"""Discover existing Container App Environment in resource group.
|
||||
|
||||
Args:
|
||||
resource_group: Resource group name
|
||||
region: Azure region (for filtering if needed)
|
||||
|
||||
Returns:
|
||||
Environment name if found, None otherwise
|
||||
"""
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"env",
|
||||
"list",
|
||||
"--resource-group",
|
||||
resource_group,
|
||||
"--query",
|
||||
"[0].name",
|
||||
"--output",
|
||||
"tsv",
|
||||
]
|
||||
|
||||
logger.info(f"Discovering existing Container App Environments in {resource_group}...")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
env_name = stdout.decode().strip()
|
||||
if env_name:
|
||||
logger.info(f"Found existing environment: {env_name}")
|
||||
return env_name
|
||||
logger.info("No existing environments found in resource group")
|
||||
return None
|
||||
logger.warning(f"Failed to query environments: {stderr.decode()}")
|
||||
return None
|
||||
|
||||
async def _deploy_to_azure(
|
||||
self, config: DeploymentConfig, entity_path: Path, auth_token: str, event_queue: asyncio.Queue[DeploymentEvent]
|
||||
) -> str:
|
||||
"""Deploy to Azure Container Apps, reusing existing environments.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
auth_token: Authentication token to inject
|
||||
event_queue: Queue for streaming progress events
|
||||
|
||||
Returns:
|
||||
Deployment URL
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment fails
|
||||
"""
|
||||
# Step 1: Try to discover existing Container App Environment
|
||||
existing_env = await self._discover_container_app_environment(config.resource_group, config.region)
|
||||
|
||||
if existing_env:
|
||||
# Use existing environment - avoids needing environment creation permissions
|
||||
logger.info(f"Reusing existing Container App Environment: {existing_env} (cost efficient, no side effects)")
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--environment",
|
||||
existing_env,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
logger.info(f"Creating new Container App '{config.app_name}' in environment '{existing_env}'...")
|
||||
else:
|
||||
# No existing environment - try to create one (may fail if no permissions)
|
||||
logger.warning(
|
||||
"No existing Container App Environment found. "
|
||||
"Attempting to create new environment (requires Microsoft.App/managedEnvironments/write permission)..."
|
||||
)
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--location",
|
||||
config.region,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Stream output line by line
|
||||
output_lines: list[str] = []
|
||||
try:
|
||||
if not process.stdout:
|
||||
raise ValueError("Failed to capture process output")
|
||||
|
||||
while True:
|
||||
# Read with timeout
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=600)
|
||||
if not line:
|
||||
break
|
||||
|
||||
line_text = line.decode().strip()
|
||||
if line_text:
|
||||
output_lines.append(line_text)
|
||||
|
||||
# Stream meaningful updates to user
|
||||
if "WARNING:" in line_text:
|
||||
# Parse and send user-friendly warnings
|
||||
if "Creating resource group" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message=f"Creating resource group '{config.resource_group}'...",
|
||||
)
|
||||
)
|
||||
elif "Creating ContainerAppEnvironment" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Setting up Container App Environment (this may take 2-3 minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Registering resource provider" in line_text:
|
||||
provider = line_text.split("provider")[-1].strip()
|
||||
if provider.endswith("..."):
|
||||
provider = provider[:-3]
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message=f"Registering Azure provider{provider}..."
|
||||
)
|
||||
)
|
||||
elif "Creating Azure Container Registry" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Container Registry for your images..."
|
||||
)
|
||||
)
|
||||
elif "No Log Analytics workspace" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Log Analytics workspace for monitoring..."
|
||||
)
|
||||
)
|
||||
elif "Building image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Building Docker image (this may take several minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Pushing image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Pushing image to Azure Container Registry..."
|
||||
)
|
||||
)
|
||||
elif "Creating Container App" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Creating your Container App...")
|
||||
)
|
||||
elif "Container app created" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Container app created successfully!")
|
||||
)
|
||||
elif "ERROR:" in line_text:
|
||||
# Stream errors immediately
|
||||
await event_queue.put(DeploymentEvent(type="deploy.error", message=line_text))
|
||||
elif "Step" in line_text and "/" in line_text:
|
||||
# Docker build steps
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message=f"Docker build: {line_text}")
|
||||
)
|
||||
elif "https://" in line_text:
|
||||
# Try to extract all URLs and check if any is on azurecontainerapps.io
|
||||
urls = re.findall(r'https://[^\s<>"]+', line_text)
|
||||
for url in urls:
|
||||
# Strip common trailing punctuation to ensure clean URL parsing
|
||||
url_clean = url.rstrip(".,;:!?'\")}]")
|
||||
parsed_url = urlparse(str(url_clean))
|
||||
host = parsed_url.hostname
|
||||
if isinstance(host, str) and (
|
||||
host == "azurecontainerapps.io" or host.endswith(".azurecontainerapps.io")
|
||||
):
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
|
||||
)
|
||||
break
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = await process.wait()
|
||||
|
||||
if return_code != 0:
|
||||
error_output = "\n".join(output_lines[-10:]) # Last 10 lines for context
|
||||
raise ValueError(f"Azure deployment failed:\n{error_output}")
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
process.kill()
|
||||
raise ValueError(
|
||||
"Azure deployment timed out after 10 minutes. Please check Azure portal for status."
|
||||
) from e
|
||||
|
||||
# Parse output to extract FQDN
|
||||
output = "\n".join(output_lines)
|
||||
logger.debug(f"Azure CLI output: {output}")
|
||||
|
||||
# Extract FQDN from output (az containerapp up returns it)
|
||||
# Format: https://<app-name>.<random-id>.<region>.azurecontainerapps.io
|
||||
deployment_url = self._extract_fqdn_from_output(output, config.app_name)
|
||||
|
||||
logger.info(f"Deployment successful: {deployment_url}")
|
||||
return deployment_url
|
||||
|
||||
def _extract_fqdn_from_output(self, output: str, app_name: str) -> str:
|
||||
"""Extract FQDN from Azure CLI output.
|
||||
|
||||
Args:
|
||||
output: Azure CLI command output
|
||||
app_name: Container app name
|
||||
|
||||
Returns:
|
||||
Full HTTPS URL to deployed app
|
||||
"""
|
||||
# Try to find FQDN in output
|
||||
for line in output.split("\n"):
|
||||
if "fqdn" in line.lower() or app_name in line:
|
||||
# Extract URL-like string
|
||||
match = re.search(r"https?://[\w\-\.]+\.azurecontainerapps\.io", line)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# If we can't extract FQDN, fail explicitly rather than return a broken URL
|
||||
logger.error(f"Could not extract FQDN from Azure CLI output. Output:\n{output}")
|
||||
raise ValueError(
|
||||
"Could not extract deployment URL from Azure CLI output. "
|
||||
"The deployment may have succeeded - check the Azure portal for your container app URL."
|
||||
)
|
||||
|
||||
async def list_deployments(self, entity_id: str | None = None) -> list[Deployment]:
|
||||
"""List all deployments, optionally filtered by entity.
|
||||
|
||||
Args:
|
||||
entity_id: Optional entity ID to filter by
|
||||
|
||||
Returns:
|
||||
List of deployment records
|
||||
"""
|
||||
if entity_id:
|
||||
return [d for d in self._deployments.values() if d.entity_id == entity_id]
|
||||
return list(self._deployments.values())
|
||||
|
||||
async def get_deployment(self, deployment_id: str) -> Deployment | None:
|
||||
"""Get deployment by ID.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID
|
||||
|
||||
Returns:
|
||||
Deployment record or None if not found
|
||||
"""
|
||||
return self._deployments.get(deployment_id)
|
||||
|
||||
async def delete_deployment(self, deployment_id: str) -> None:
|
||||
"""Delete deployment from Azure Container Apps.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment not found or deletion fails
|
||||
"""
|
||||
deployment = self._deployments.get(deployment_id)
|
||||
if not deployment:
|
||||
raise ValueError(f"Deployment {deployment_id} not found")
|
||||
|
||||
# Execute: az containerapp delete
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"delete",
|
||||
"--name",
|
||||
deployment.app_name,
|
||||
"--resource-group",
|
||||
deployment.resource_group,
|
||||
"--yes", # Skip confirmation
|
||||
]
|
||||
|
||||
logger.info(f"Deleting deployment: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_output = stderr.decode() if stderr else stdout.decode()
|
||||
raise ValueError(f"Deployment deletion failed: {error_output}")
|
||||
|
||||
# Remove from store
|
||||
del self._deployments[deployment_id]
|
||||
logger.info(f"Deployment {deployment_id} deleted successfully")
|
||||
@@ -0,0 +1,968 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework entity discovery implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .models._discovery_models import EntityInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityDiscovery:
|
||||
"""Discovery for Agent Framework entities - agents and workflows."""
|
||||
|
||||
def __init__(self, entities_dir: str | None = None):
|
||||
"""Initialize entity discovery.
|
||||
|
||||
Args:
|
||||
entities_dir: Directory to scan for entities (optional)
|
||||
"""
|
||||
self.entities_dir = entities_dir
|
||||
self._entities: dict[str, EntityInfo] = {}
|
||||
self._loaded_objects: dict[str, Any] = {}
|
||||
self._cleanup_hooks: dict[str, list[Any]] = {}
|
||||
|
||||
async def discover_entities(self) -> list[EntityInfo]:
|
||||
"""Scan for Agent Framework entities.
|
||||
|
||||
Returns:
|
||||
List of discovered entities
|
||||
"""
|
||||
if not self.entities_dir:
|
||||
logger.info("No Agent Framework entities directory configured")
|
||||
return []
|
||||
|
||||
entities_dir = Path(self.entities_dir).resolve() # noqa: ASYNC240
|
||||
await self._scan_entities_directory(entities_dir)
|
||||
|
||||
logger.info(f"Discovered {len(self._entities)} Agent Framework entities")
|
||||
return self.list_entities()
|
||||
|
||||
def get_entity_info(self, entity_id: str) -> EntityInfo | None:
|
||||
"""Get entity metadata.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
Entity information or None if not found
|
||||
"""
|
||||
return self._entities.get(entity_id)
|
||||
|
||||
def get_entity_object(self, entity_id: str) -> Any | None:
|
||||
"""Get the actual loaded entity object.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
Entity object or None if not found
|
||||
"""
|
||||
return self._loaded_objects.get(entity_id)
|
||||
|
||||
async def load_entity(self, entity_id: str, checkpoint_manager: Any = None) -> Any:
|
||||
"""Load entity on-demand and inject checkpoint storage for workflows.
|
||||
|
||||
This method implements lazy loading by importing the entity module only when needed.
|
||||
In-memory entities are returned from cache immediately.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
checkpoint_manager: Optional checkpoint manager for workflow storage injection
|
||||
|
||||
Returns:
|
||||
Loaded entity object
|
||||
|
||||
Raises:
|
||||
ValueError: If entity not found or cannot be loaded
|
||||
"""
|
||||
# Check if already loaded (includes in-memory entities)
|
||||
if entity_id in self._loaded_objects:
|
||||
logger.debug(f"Entity {entity_id} already loaded (cache hit)")
|
||||
return self._loaded_objects[entity_id]
|
||||
|
||||
# Get entity metadata
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if not entity_info:
|
||||
raise ValueError(f"Entity {entity_id} not found in registry")
|
||||
|
||||
# In-memory entities should never reach here (they're pre-loaded)
|
||||
if entity_info.source == "in_memory":
|
||||
raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache")
|
||||
|
||||
logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})")
|
||||
|
||||
# Load based on source - only directory and in-memory are supported
|
||||
if entity_info.source == "directory":
|
||||
entity_obj = await self._load_directory_entity(entity_id, entity_info)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported entity source: {entity_info.source}. "
|
||||
f"Only 'directory' and 'in-memory' sources are supported."
|
||||
)
|
||||
|
||||
# Note: Checkpoint storage is now injected at runtime via run() parameter,
|
||||
# not at load time. This provides cleaner architecture and explicit control flow.
|
||||
# See _executor.py _execute_workflow() for runtime checkpoint storage injection.
|
||||
|
||||
# Enrich metadata with actual entity data
|
||||
# Don't pass entity_type if it's "unknown" - let inference determine the real type
|
||||
enriched_info = await self.create_entity_info_from_object(
|
||||
entity_obj,
|
||||
entity_type=entity_info.type if entity_info.type != "unknown" else None,
|
||||
source=entity_info.source,
|
||||
)
|
||||
# IMPORTANT: Preserve the original entity_id (enrichment generates a new one)
|
||||
enriched_info.id = entity_id
|
||||
# Preserve the original path from sparse metadata
|
||||
if "path" in entity_info.metadata:
|
||||
enriched_info.metadata["path"] = entity_info.metadata["path"]
|
||||
# Now that we have the path, properly check deployment support
|
||||
entity_path = Path(entity_info.metadata["path"])
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(entity_path, entity_info.source)
|
||||
enriched_info.deployment_supported = deployment_supported
|
||||
enriched_info.deployment_reason = deployment_reason
|
||||
enriched_info.metadata["lazy_loaded"] = True
|
||||
self._entities[entity_id] = enriched_info
|
||||
|
||||
# Cache the loaded object
|
||||
self._loaded_objects[entity_id] = entity_obj
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.info(f"Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
|
||||
|
||||
return entity_obj
|
||||
|
||||
async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any:
|
||||
"""Load entity from directory (imports module).
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
entity_info: Entity metadata
|
||||
|
||||
Returns:
|
||||
Loaded entity object
|
||||
"""
|
||||
# Get directory path from metadata
|
||||
dir_path = Path(entity_info.metadata.get("path", ""))
|
||||
if not dir_path.exists(): # noqa: ASYNC240
|
||||
raise ValueError(f"Entity directory not found: {dir_path}")
|
||||
|
||||
# Load .env if it exists
|
||||
if dir_path.is_dir(): # noqa: ASYNC240
|
||||
self._load_env_for_entity(dir_path)
|
||||
else:
|
||||
self._load_env_for_entity(dir_path.parent)
|
||||
|
||||
# Import the module
|
||||
if dir_path.is_dir(): # noqa: ASYNC240
|
||||
# Directory-based entity - try different import patterns
|
||||
import_patterns = [
|
||||
entity_id,
|
||||
f"{entity_id}.agent",
|
||||
f"{entity_id}.workflow",
|
||||
]
|
||||
|
||||
# Track import errors to provide meaningful feedback
|
||||
import_errors: list[tuple[str, Exception]] = []
|
||||
|
||||
for pattern in import_patterns:
|
||||
module, error = self._load_module_from_pattern(pattern)
|
||||
if error:
|
||||
import_errors.append((pattern, error))
|
||||
if module:
|
||||
# Find entity in module - pass entity_id so registration uses correct ID
|
||||
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
|
||||
if entity_obj:
|
||||
return entity_obj
|
||||
|
||||
# If we have import errors, raise the most informative one
|
||||
if import_errors:
|
||||
# Prefer errors from the main module pattern (entity_id) or agent submodule
|
||||
for pattern, error in import_errors:
|
||||
if pattern == entity_id or pattern.endswith(".agent"):
|
||||
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
|
||||
# Fall back to first error
|
||||
pattern, error = import_errors[0]
|
||||
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
|
||||
|
||||
raise ValueError(f"No valid entity found in {dir_path}")
|
||||
# File-based entity
|
||||
module = self._load_module_from_file(dir_path, entity_id)
|
||||
if module:
|
||||
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
|
||||
if entity_obj:
|
||||
return entity_obj
|
||||
|
||||
raise ValueError(f"No valid entity found in {dir_path}")
|
||||
|
||||
def list_entities(self) -> list[EntityInfo]:
|
||||
"""List all discovered entities.
|
||||
|
||||
Returns:
|
||||
List of all entity information
|
||||
"""
|
||||
return list(self._entities.values())
|
||||
|
||||
def get_cleanup_hooks(self, entity_id: str) -> list[Any]:
|
||||
"""Get cleanup hooks registered for an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks for the entity
|
||||
"""
|
||||
return self._cleanup_hooks.get(entity_id, [])
|
||||
|
||||
def invalidate_entity(self, entity_id: str) -> None:
|
||||
"""Invalidate (clear cache for) an entity to enable hot reload.
|
||||
|
||||
This removes the entity from the loaded objects cache and clears its module
|
||||
from Python's sys.modules cache. The entity metadata remains, so it will be
|
||||
reimported on next access.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier to invalidate
|
||||
"""
|
||||
# Check if entity is in-memory - these cannot be invalidated
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if entity_info and entity_info.source == "in_memory":
|
||||
logger.warning(
|
||||
f"Attempted to invalidate in-memory entity {entity_id} - ignoring "
|
||||
f"(in-memory entities cannot be reloaded)"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove from loaded objects cache
|
||||
if entity_id in self._loaded_objects:
|
||||
del self._loaded_objects[entity_id]
|
||||
logger.info(f"Cleared loaded object cache for: {entity_id}")
|
||||
|
||||
# Clear from Python's module cache (including submodules)
|
||||
keys_to_delete = [
|
||||
module_name
|
||||
for module_name in sys.modules
|
||||
if module_name == entity_id or module_name.startswith(f"{entity_id}.")
|
||||
]
|
||||
for key in keys_to_delete:
|
||||
del sys.modules[key]
|
||||
logger.debug(f"Cleared module cache: {key}")
|
||||
|
||||
# Reset lazy_loaded flag in metadata
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if entity_info and "lazy_loaded" in entity_info.metadata:
|
||||
entity_info.metadata["lazy_loaded"] = False
|
||||
|
||||
logger.info(f"Entity invalidated: {entity_id} (will reload on next access)")
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Invalidate all cached entities.
|
||||
|
||||
Useful for forcing a complete reload of all entities.
|
||||
"""
|
||||
entity_ids = list(self._loaded_objects.keys())
|
||||
for entity_id in entity_ids:
|
||||
self.invalidate_entity(entity_id)
|
||||
logger.info(f"Invalidated {len(entity_ids)} entities")
|
||||
|
||||
def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
|
||||
"""Register an entity with both metadata and object.
|
||||
|
||||
Args:
|
||||
entity_id: Unique entity identifier
|
||||
entity_info: Entity metadata
|
||||
entity_object: Actual entity object for execution
|
||||
"""
|
||||
self._entities[entity_id] = entity_info
|
||||
self._loaded_objects[entity_id] = entity_object
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_object)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
|
||||
|
||||
async def create_entity_info_from_object(
|
||||
self, entity_object: Any, entity_type: str | None = None, source: str = "in_memory"
|
||||
) -> EntityInfo:
|
||||
"""Create EntityInfo from Agent Framework entity object.
|
||||
|
||||
Args:
|
||||
entity_object: Agent Framework entity object
|
||||
entity_type: Optional entity type override
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
|
||||
Returns:
|
||||
EntityInfo with Agent Framework specific metadata
|
||||
"""
|
||||
# Determine entity type if not provided
|
||||
if entity_type is None:
|
||||
entity_type = "agent"
|
||||
# Check if it's a workflow
|
||||
if hasattr(entity_object, "get_executors_list") or hasattr(entity_object, "executors"):
|
||||
entity_type = "workflow"
|
||||
|
||||
# Extract metadata with improved fallback naming
|
||||
name = getattr(entity_object, "name", None)
|
||||
if not name:
|
||||
# In-memory entities: use class name as it's more readable than UUID
|
||||
class_name = entity_object.__class__.__name__
|
||||
name = f"{entity_type.title()} {class_name}"
|
||||
description = getattr(entity_object, "description", "")
|
||||
|
||||
# Generate entity ID using Agent Framework specific naming
|
||||
entity_id = self._generate_entity_id(entity_object, entity_type, source)
|
||||
|
||||
# Extract tools/executors using Agent Framework specific logic
|
||||
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_provider_list = None
|
||||
middlewares_list = None
|
||||
|
||||
if entity_type == "agent":
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
agent_meta = extract_agent_metadata(entity_object)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_provider_list = agent_meta["context_provider"]
|
||||
middlewares_list = agent_meta["middleware"]
|
||||
|
||||
# Log helpful info about agent capabilities (before creating EntityInfo)
|
||||
if entity_type == "agent":
|
||||
has_run = hasattr(entity_object, "run")
|
||||
|
||||
if not has_run:
|
||||
logger.warning(f"Agent '{entity_id}' lacks run() method. May not work.")
|
||||
|
||||
# Check deployment support based on source
|
||||
# For directory-based entities, we need the path to verify deployment support
|
||||
deployment_supported = False
|
||||
deployment_reason = "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
if source == "directory":
|
||||
# Directory-based entity - will be checked properly after enrichment when path is available
|
||||
# For now, mark as potentially deployable - will be re-evaluated after enrichment
|
||||
deployment_supported = True
|
||||
deployment_reason = "Ready for deployment (pending path verification)"
|
||||
|
||||
class_name = type(entity_object).__name__
|
||||
|
||||
# Create EntityInfo with Agent Framework specifics
|
||||
return EntityInfo(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
description=description,
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
source=source, # IMPORTANT: Pass the source parameter
|
||||
tools=[str(tool) for tool in (tools_list or [])],
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_provider=context_provider_list,
|
||||
middleware=middlewares_list,
|
||||
executors=tools_list if entity_type == "workflow" else [],
|
||||
input_schema={"type": "string"}, # Default schema
|
||||
start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"source": "agent_framework_object",
|
||||
"class_name": class_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def _scan_entities_directory(self, entities_dir: Path) -> None:
|
||||
"""Scan the entities directory for Agent Framework entities (lazy loading).
|
||||
|
||||
This method scans the filesystem WITHOUT importing modules, creating sparse
|
||||
metadata that will be enriched on-demand when entities are accessed.
|
||||
|
||||
Args:
|
||||
entities_dir: Directory to scan for entities
|
||||
"""
|
||||
if not entities_dir.exists(): # noqa: ASYNC240
|
||||
logger.warning(f"Entities directory not found: {entities_dir}")
|
||||
return
|
||||
|
||||
logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...")
|
||||
|
||||
# Add entities directory to Python path if not already there
|
||||
entities_dir_str = str(entities_dir)
|
||||
if entities_dir_str not in sys.path:
|
||||
sys.path.insert(0, entities_dir_str)
|
||||
|
||||
# Scan for directories and Python files WITHOUT importing
|
||||
for item in entities_dir.iterdir(): # noqa: ASYNC240
|
||||
if item.name.startswith(".") or item.name == "__pycache__":
|
||||
continue
|
||||
|
||||
if item.is_dir() and self._looks_like_entity(item):
|
||||
# Directory-based entity - create sparse metadata
|
||||
self._register_sparse_entity(item)
|
||||
elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
|
||||
# Single file entity - create sparse metadata
|
||||
self._register_sparse_file_entity(item)
|
||||
|
||||
def _looks_like_entity(self, dir_path: Path) -> bool:
|
||||
"""Check if directory contains an entity (without importing).
|
||||
|
||||
Args:
|
||||
dir_path: Directory to check
|
||||
|
||||
Returns:
|
||||
True if directory appears to contain an entity
|
||||
"""
|
||||
return (
|
||||
(dir_path / "agent.py").exists()
|
||||
or (dir_path / "workflow.py").exists()
|
||||
or (dir_path / "__init__.py").exists()
|
||||
)
|
||||
|
||||
def _detect_entity_type(self, dir_path: Path) -> str:
|
||||
"""Detect entity type from directory structure (without importing).
|
||||
|
||||
Uses filename conventions to determine entity type:
|
||||
- workflow.py → "workflow"
|
||||
- agent.py → "agent"
|
||||
- both or neither → "unknown"
|
||||
|
||||
Args:
|
||||
dir_path: Directory to analyze
|
||||
|
||||
Returns:
|
||||
Entity type: "workflow", "agent", or "unknown"
|
||||
"""
|
||||
has_agent = (dir_path / "agent.py").exists()
|
||||
has_workflow = (dir_path / "workflow.py").exists()
|
||||
|
||||
if has_agent and has_workflow:
|
||||
# Both files exist - ambiguous, mark as unknown
|
||||
return "unknown"
|
||||
if has_workflow:
|
||||
return "workflow"
|
||||
if has_agent:
|
||||
return "agent"
|
||||
# Has __init__.py but no specific file
|
||||
return "unknown"
|
||||
|
||||
def _check_deployment_support(self, entity_path: Path, source: str) -> tuple[bool, str | None]:
|
||||
"""Check if entity can be deployed to Azure Container Apps.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory or file
|
||||
source: Entity source ("directory" or "in_memory")
|
||||
|
||||
Returns:
|
||||
Tuple of (supported, reason) explaining deployment eligibility
|
||||
"""
|
||||
# In-memory entities cannot be deployed
|
||||
if source == "in_memory":
|
||||
return False, "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
# File-based entities need a directory structure for deployment
|
||||
if not entity_path.is_dir():
|
||||
return False, "Only directory-based entities can be deployed"
|
||||
|
||||
# Must have __init__.py
|
||||
if not (entity_path / "__init__.py").exists():
|
||||
return False, "Missing __init__.py file"
|
||||
|
||||
# Passed all checks
|
||||
return True, "Ready for deployment"
|
||||
|
||||
def _register_sparse_entity(self, dir_path: Path) -> None:
|
||||
"""Register entity with sparse metadata (no import).
|
||||
|
||||
Args:
|
||||
dir_path: Entity directory
|
||||
"""
|
||||
entity_id = dir_path.name
|
||||
entity_type = self._detect_entity_type(dir_path)
|
||||
|
||||
# Check deployment support
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(dir_path, "directory")
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
name=entity_id.replace("_", " ").title(),
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
tools=[], # Sparse - will be populated on load
|
||||
description="", # Sparse - will be populated on load
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(dir_path),
|
||||
"discovered": True,
|
||||
"lazy_loaded": False,
|
||||
},
|
||||
)
|
||||
|
||||
self._entities[entity_id] = entity_info
|
||||
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
|
||||
|
||||
def _has_entity_exports(self, file_path: Path) -> bool:
|
||||
"""Check if a Python file has entity exports (agent or workflow) using AST parsing.
|
||||
|
||||
This safely checks for module-level assignments like:
|
||||
- agent = Agent(...)
|
||||
- workflow = WorkflowBuilder(start_executor=...)...
|
||||
|
||||
Args:
|
||||
file_path: Python file to check
|
||||
|
||||
Returns:
|
||||
True if file has 'agent' or 'workflow' exports
|
||||
"""
|
||||
try:
|
||||
# Read and parse the file's AST
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(file_path))
|
||||
|
||||
# Look for module-level assignments of 'agent' or 'workflow'
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ("agent", "workflow"):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse {file_path} for entity exports: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _register_sparse_file_entity(self, file_path: Path) -> None:
|
||||
"""Register file-based entity with sparse metadata (no import).
|
||||
|
||||
Args:
|
||||
file_path: Entity Python file
|
||||
"""
|
||||
# Check if file has valid entity exports using AST parsing
|
||||
if not self._has_entity_exports(file_path):
|
||||
logger.debug(f"Skipping {file_path.name} - no 'agent' or 'workflow' exports found")
|
||||
return
|
||||
|
||||
entity_id = file_path.stem
|
||||
|
||||
# Check deployment support (file-based entities cannot be deployed)
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(file_path, "directory")
|
||||
|
||||
# File-based entities are typically agents, but we can't know for sure without importing
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
name=entity_id.replace("_", " ").title(),
|
||||
type="unknown", # Will be determined on load
|
||||
framework="agent_framework",
|
||||
tools=[],
|
||||
description="",
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(file_path),
|
||||
"discovered": True,
|
||||
"lazy_loaded": False,
|
||||
},
|
||||
)
|
||||
|
||||
self._entities[entity_id] = entity_info
|
||||
logger.debug(f"Registered sparse file entity: {entity_id}")
|
||||
|
||||
def _load_env_for_entity(self, entity_path: Path) -> bool:
|
||||
"""Load .env file for an entity.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory
|
||||
|
||||
Returns:
|
||||
True if .env was loaded successfully
|
||||
"""
|
||||
# Check for .env in the entity folder first
|
||||
env_file = entity_path / ".env"
|
||||
if self._load_env_file(env_file):
|
||||
return True
|
||||
|
||||
# Check one level up (the entities directory) for safety
|
||||
if self.entities_dir:
|
||||
entities_dir = Path(self.entities_dir).resolve()
|
||||
entities_env = entities_dir / ".env"
|
||||
if self._load_env_file(entities_env):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _load_env_file(self, env_path: Path) -> bool:
|
||||
"""Load environment variables from .env file.
|
||||
|
||||
Args:
|
||||
env_path: Path to .env file
|
||||
|
||||
Returns:
|
||||
True if file was loaded successfully
|
||||
"""
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=True)
|
||||
logger.debug(f"Loaded .env from {env_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_module_from_pattern(self, pattern: str) -> tuple[Any | None, Exception | None]:
|
||||
"""Load module using import pattern.
|
||||
|
||||
Args:
|
||||
pattern: Import pattern to try
|
||||
|
||||
Returns:
|
||||
Tuple of (loaded module or None, error or None)
|
||||
"""
|
||||
try:
|
||||
# Check if module exists first
|
||||
spec = importlib.util.find_spec(pattern)
|
||||
if spec is None:
|
||||
return None, None
|
||||
|
||||
module = importlib.import_module(pattern)
|
||||
logger.debug(f"Successfully imported {pattern}")
|
||||
return module, None
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
# Distinguish between "module pattern doesn't exist" vs "module has import errors"
|
||||
# If the missing module is the pattern itself, it's just not found (try next pattern)
|
||||
# If the missing module is something else (a dependency), capture the error
|
||||
missing_module = getattr(e, "name", None)
|
||||
if missing_module and missing_module != pattern and not pattern.endswith(f".{missing_module}"):
|
||||
# The module exists but has an import error (missing dependency)
|
||||
logger.warning(f"Error importing {pattern}: {e}")
|
||||
return None, e
|
||||
# The module pattern itself doesn't exist - this is expected, try next pattern
|
||||
logger.debug(f"Import pattern {pattern} not found")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
# Capture the actual error for better error messages
|
||||
logger.warning(f"Error importing {pattern}: {e}")
|
||||
return None, e
|
||||
|
||||
def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None:
|
||||
"""Load module directly from file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to Python file
|
||||
module_name: Name to assign to module
|
||||
|
||||
Returns:
|
||||
Loaded module or None if failed
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module # Add to sys.modules for proper imports
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
logger.debug(f"Successfully loaded module from {file_path}")
|
||||
return module
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading module from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any:
|
||||
"""Find agent or workflow entity in a loaded module.
|
||||
|
||||
Args:
|
||||
module: Loaded Python module
|
||||
entity_id: Expected entity identifier to register with
|
||||
module_path: Path to module for metadata
|
||||
|
||||
Returns:
|
||||
Loaded entity object, or None if not found
|
||||
"""
|
||||
# Look for explicit variable names first
|
||||
candidates = [
|
||||
("agent", getattr(module, "agent", None)),
|
||||
("workflow", getattr(module, "workflow", None)),
|
||||
]
|
||||
|
||||
for obj_type, obj in candidates:
|
||||
if obj is None:
|
||||
continue
|
||||
|
||||
if self._is_valid_entity(obj, obj_type):
|
||||
# Register with the correct entity_id (from directory name)
|
||||
# Store the object directly in _loaded_objects so we can return it
|
||||
self._loaded_objects[entity_id] = obj
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
|
||||
"""Check if object is a valid agent or workflow using duck typing.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
expected_type: Expected type ("agent" or "workflow")
|
||||
|
||||
Returns:
|
||||
True if object is valid for the expected type
|
||||
"""
|
||||
if expected_type == "agent":
|
||||
return self._is_valid_agent(obj)
|
||||
if expected_type == "workflow":
|
||||
return self._is_valid_workflow(obj)
|
||||
return False
|
||||
|
||||
def _is_valid_agent(self, obj: Any) -> bool:
|
||||
"""Check if object is a valid Agent Framework agent.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
|
||||
Returns:
|
||||
True if object appears to be a valid agent
|
||||
"""
|
||||
try:
|
||||
# Try to import SupportsAgentRun for proper type checking
|
||||
try:
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
if isinstance(obj, SupportsAgentRun):
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback to duck typing for agent protocol
|
||||
# Agent must have run() method, plus id and name
|
||||
has_run = hasattr(obj, "run")
|
||||
if has_run and hasattr(obj, "id") and hasattr(obj, "name"):
|
||||
return True
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _is_valid_workflow(self, obj: Any) -> bool:
|
||||
"""Check if object is a valid Agent Framework workflow.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
|
||||
Returns:
|
||||
True if object appears to be a valid workflow
|
||||
"""
|
||||
# Check for workflow - must have run (streaming via stream=True) and executors
|
||||
has_run = hasattr(obj, "run")
|
||||
return has_run and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
|
||||
|
||||
async def _register_entity_from_object(
|
||||
self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
|
||||
) -> None:
|
||||
"""Register an entity from a live object.
|
||||
|
||||
Args:
|
||||
obj: Entity object
|
||||
obj_type: Type of entity ("agent" or "workflow")
|
||||
module_path: Path to module for metadata
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
"""
|
||||
try:
|
||||
# Generate entity ID with source information
|
||||
entity_id = self._generate_entity_id(obj, obj_type, source)
|
||||
|
||||
# Extract metadata from the live object with improved fallback naming
|
||||
name = getattr(obj, "name", None)
|
||||
if not name:
|
||||
# Use class name as it's more readable than UUID
|
||||
class_name = obj.__class__.__name__
|
||||
name = f"{obj_type.title()} {class_name}"
|
||||
description = getattr(obj, "description", None)
|
||||
tools = await self._extract_tools_from_object(obj, obj_type)
|
||||
|
||||
# Create EntityInfo
|
||||
tools_union: list[str | dict[str, Any]] | None = None
|
||||
if tools:
|
||||
tools_union = [tool for tool in tools]
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_provider_list = None
|
||||
middlewares_list = None
|
||||
|
||||
if obj_type == "agent":
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
agent_meta = extract_agent_metadata(obj)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_provider_list = agent_meta["context_provider"]
|
||||
middlewares_list = agent_meta["middleware"]
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
type=obj_type,
|
||||
name=name,
|
||||
framework="agent_framework",
|
||||
description=description,
|
||||
tools=tools_union,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_provider=context_provider_list,
|
||||
middleware=middlewares_list,
|
||||
metadata={
|
||||
"module_path": module_path,
|
||||
"entity_type": obj_type,
|
||||
"source": source,
|
||||
"class_name": type(obj).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
# Register the entity
|
||||
self.register_entity(entity_id, entity_info, obj)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering entity from {source}: {e}")
|
||||
|
||||
async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
|
||||
"""Extract tool/executor names from a live object.
|
||||
|
||||
Args:
|
||||
obj: Entity object
|
||||
obj_type: Type of entity
|
||||
|
||||
Returns:
|
||||
List of tool/executor names
|
||||
"""
|
||||
tools: list[str] = []
|
||||
|
||||
try:
|
||||
if obj_type == "agent":
|
||||
chat_options = getattr(obj, "default_options", None)
|
||||
chat_options_tools: object | None = None
|
||||
if isinstance(chat_options, dict):
|
||||
chat_options_dict = cast(dict[str, Any], chat_options)
|
||||
chat_options_tools = chat_options_dict.get("tools")
|
||||
|
||||
if chat_options_tools is not None:
|
||||
tool_iterable: list[object] = (
|
||||
cast(list[object], chat_options_tools)
|
||||
if isinstance(chat_options_tools, list)
|
||||
else [chat_options_tools]
|
||||
)
|
||||
for tool_obj in tool_iterable:
|
||||
tool_name = getattr(tool_obj, "__name__", None)
|
||||
if isinstance(tool_name, str):
|
||||
tools.append(tool_name)
|
||||
continue
|
||||
|
||||
named_tool = getattr(tool_obj, "name", None)
|
||||
if isinstance(named_tool, str):
|
||||
tools.append(named_tool)
|
||||
else:
|
||||
tools.append(str(tool_obj))
|
||||
else:
|
||||
agent_tools = getattr(obj, "tools", None)
|
||||
if isinstance(agent_tools, list):
|
||||
for tool_obj in cast(list[object], agent_tools):
|
||||
tool_name = getattr(tool_obj, "__name__", None)
|
||||
if isinstance(tool_name, str):
|
||||
tools.append(tool_name)
|
||||
continue
|
||||
|
||||
named_tool = getattr(tool_obj, "name", None)
|
||||
if isinstance(named_tool, str):
|
||||
tools.append(named_tool)
|
||||
else:
|
||||
tools.append(str(tool_obj))
|
||||
|
||||
elif obj_type == "workflow":
|
||||
if hasattr(obj, "get_executors_list"):
|
||||
executor_objects = obj.get_executors_list()
|
||||
if isinstance(executor_objects, list):
|
||||
for executor_obj in cast(list[object], executor_objects):
|
||||
tools.append(str(getattr(executor_obj, "id", executor_obj)))
|
||||
elif hasattr(obj, "executors"):
|
||||
executors = obj.executors
|
||||
if isinstance(executors, list):
|
||||
for executor_obj in cast(list[object], executors):
|
||||
tools.append(str(getattr(executor_obj, "id", executor_obj)))
|
||||
elif isinstance(executors, dict):
|
||||
executors_dict = cast(dict[str, Any], executors)
|
||||
for key_obj in executors_dict:
|
||||
tools.append(str(key_obj))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
|
||||
|
||||
return tools
|
||||
|
||||
def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
|
||||
"""Generate unique entity ID with UUID suffix for collision resistance.
|
||||
|
||||
Args:
|
||||
entity: Entity object
|
||||
entity_type: Type of entity (agent, workflow, etc.)
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
|
||||
Returns:
|
||||
Unique entity ID with format: {type}_{source}_{name}_{uuid}
|
||||
"""
|
||||
import re
|
||||
|
||||
# Extract base name with priority: name -> id -> class_name
|
||||
if hasattr(entity, "name") and entity.name:
|
||||
base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
|
||||
elif hasattr(entity, "id") and entity.id:
|
||||
base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
|
||||
elif hasattr(entity, "__class__"):
|
||||
class_name = entity.__class__.__name__
|
||||
# Convert CamelCase to kebab-case
|
||||
base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
|
||||
else:
|
||||
base_name = "entity"
|
||||
|
||||
# Generate full UUID for guaranteed uniqueness
|
||||
full_uuid = uuid.uuid4().hex
|
||||
|
||||
return f"{entity_type}_{source}_{base_name}_{full_uuid}"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI integration for DevUI - proxy support for OpenAI Responses API."""
|
||||
|
||||
from ._executor import OpenAIExecutor
|
||||
|
||||
__all__ = [
|
||||
"OpenAIExecutor",
|
||||
]
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Executor - proxies requests to OpenAI Responses API.
|
||||
|
||||
This executor mirrors the AgentFrameworkExecutor interface but routes
|
||||
requests to OpenAI's API instead of executing local entities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from openai import APIStatusError, AsyncOpenAI, AsyncStream, AuthenticationError, PermissionDeniedError, RateLimitError
|
||||
from openai.types.responses import Response, ResponseStreamEvent
|
||||
|
||||
from .._conversations import ConversationStore
|
||||
from ..models import AgentFrameworkRequest, OpenAIResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_error_details(body: Any) -> tuple[str | None, str | None, str | None]:
|
||||
"""Extract typed OpenAI error fields from error body payload."""
|
||||
if not isinstance(body, dict):
|
||||
return None, None, None
|
||||
|
||||
error_dict: dict[str, Any] = body.get("error") # type: ignore[assignment, reportUnknownVariableType]
|
||||
if not isinstance(error_dict, dict):
|
||||
return None, None, None
|
||||
|
||||
message = error_dict.get("message")
|
||||
error_type = error_dict.get("type")
|
||||
code = error_dict.get("code")
|
||||
|
||||
return (
|
||||
message if isinstance(message, str) else None,
|
||||
error_type if isinstance(error_type, str) else None,
|
||||
code if isinstance(code, str) else None,
|
||||
)
|
||||
|
||||
|
||||
class OpenAIExecutor:
|
||||
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
|
||||
|
||||
This executor provides the same interface as AgentFrameworkExecutor but proxies
|
||||
requests to OpenAI's Responses API instead of executing local entities.
|
||||
|
||||
Key features:
|
||||
- Same execute_streaming() and execute_sync() interface
|
||||
- Shares ConversationStore with local executor
|
||||
- Configured via OPENAI_API_KEY environment variable
|
||||
- Supports all OpenAI Responses API parameters
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
"""Initialize OpenAI executor.
|
||||
|
||||
Args:
|
||||
conversation_store: Shared conversation store (works for both local and OpenAI)
|
||||
"""
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
# Load configuration from environment
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
self._client: AsyncOpenAI | None = None
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if OpenAI executor is properly configured.
|
||||
|
||||
Returns:
|
||||
True if OPENAI_API_KEY is set
|
||||
"""
|
||||
return self.api_key is not None
|
||||
|
||||
def _get_client(self) -> AsyncOpenAI:
|
||||
"""Get or create OpenAI async client.
|
||||
|
||||
Returns:
|
||||
AsyncOpenAI client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If OPENAI_API_KEY not configured
|
||||
"""
|
||||
if self._client is None:
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||||
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
logger.debug(f"Created OpenAI client with base_url: {self.base_url}")
|
||||
|
||||
return self._client
|
||||
|
||||
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any]:
|
||||
"""Execute request via OpenAI and stream results in OpenAI format.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_streaming() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Yields:
|
||||
OpenAI ResponseStreamEvent objects (already in correct format!)
|
||||
"""
|
||||
if not self.is_configured:
|
||||
logger.error("OpenAI executor not configured (missing OPENAI_API_KEY)")
|
||||
# Emit proper response.failed event
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": "OpenAI not configured on server. Set OPENAI_API_KEY environment variable.",
|
||||
"type": "configuration_error",
|
||||
"code": "openai_not_configured",
|
||||
},
|
||||
},
|
||||
}
|
||||
return
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields that OpenAI doesn't recognize
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Conversation ID is now from OpenAI (created via /v1/conversations proxy)
|
||||
# so we can pass it through!
|
||||
|
||||
# Force streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API: model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns AsyncStream[ResponseStreamEvent]
|
||||
stream: AsyncStream[ResponseStreamEvent] = await client.responses.create(
|
||||
**params,
|
||||
stream=True, # Force streaming
|
||||
)
|
||||
|
||||
# Yield events directly - they're already ResponseStreamEvent objects!
|
||||
# No conversion needed - OpenAI SDK returns proper typed objects
|
||||
async for event in stream:
|
||||
yield event
|
||||
|
||||
except AuthenticationError as e:
|
||||
# 401 - Invalid API key or authentication issue
|
||||
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "authentication_error",
|
||||
"code": code or "invalid_api_key",
|
||||
},
|
||||
},
|
||||
}
|
||||
except PermissionDeniedError as e:
|
||||
# 403 - Permission denied
|
||||
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "permission_denied",
|
||||
"code": code or "insufficient_permissions",
|
||||
},
|
||||
},
|
||||
}
|
||||
except RateLimitError as e:
|
||||
# 429 - Rate limit exceeded
|
||||
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "rate_limit_error",
|
||||
"code": code or "rate_limit_exceeded",
|
||||
},
|
||||
},
|
||||
}
|
||||
except APIStatusError as e:
|
||||
# Other OpenAI API errors
|
||||
logger.error(f"OpenAI API error: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "api_error",
|
||||
"code": code or "unknown_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
# Catch-all for unexpected errors
|
||||
logger.error(f"Unexpected error in OpenAI proxy: {e}", exc_info=True)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": f"Unexpected error: {e!s}",
|
||||
"type": "internal_error",
|
||||
"code": "unexpected_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
|
||||
"""Execute request via OpenAI and return complete response.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_sync() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Returns:
|
||||
Final OpenAI Response object
|
||||
|
||||
Raises:
|
||||
ValueError: If OpenAI not configured
|
||||
Exception: If OpenAI API call fails
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("OpenAI not configured on server. Set OPENAI_API_KEY environment variable.")
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Force non-streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API (non-streaming): model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns Response object
|
||||
response: Response = await client.responses.create(
|
||||
**params,
|
||||
stream=False, # Force non-streaming
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI proxy error: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the OpenAI client and release resources."""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
logger.debug("Closed OpenAI client")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Session management for agent execution tracking."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestRecord(TypedDict):
|
||||
"""Tracked execution request data."""
|
||||
|
||||
id: str
|
||||
timestamp: datetime
|
||||
entity_id: str
|
||||
executor: str
|
||||
input: Any
|
||||
model: str
|
||||
stream: bool
|
||||
execution_time: NotRequired[float]
|
||||
status: NotRequired[str]
|
||||
|
||||
|
||||
class SessionData(TypedDict):
|
||||
"""Stored session state."""
|
||||
|
||||
id: str
|
||||
created_at: datetime
|
||||
requests: list[RequestRecord]
|
||||
context: dict[str, Any]
|
||||
active: bool
|
||||
|
||||
|
||||
SessionSummary = dict[str, Any]
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages execution sessions for tracking requests and context."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the session manager."""
|
||||
self.sessions: dict[str, SessionData] = {}
|
||||
|
||||
def create_session(self, session_id: str | None = None) -> str:
|
||||
"""Create a new execution session.
|
||||
|
||||
Args:
|
||||
session_id: Optional session ID, if not provided a new one is generated
|
||||
|
||||
Returns:
|
||||
Session ID
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
self.sessions[session_id] = {
|
||||
"id": session_id,
|
||||
"created_at": datetime.now(),
|
||||
"requests": [],
|
||||
"context": {},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
logger.debug(f"Created session: {session_id}")
|
||||
return session_id
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData | None:
|
||||
"""Get session information.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Session data or None if not found
|
||||
"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def close_session(self, session_id: str) -> None:
|
||||
"""Close and cleanup a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to close
|
||||
"""
|
||||
if session_id in self.sessions:
|
||||
self.sessions[session_id]["active"] = False
|
||||
logger.debug(f"Closed session: {session_id}")
|
||||
|
||||
def add_request_record(
|
||||
self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str
|
||||
) -> str:
|
||||
"""Add a request record to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
entity_id: ID of the entity being executed
|
||||
executor_name: Name of the executor
|
||||
request_input: Input for the request
|
||||
model: Model name
|
||||
|
||||
Returns:
|
||||
Request ID
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return ""
|
||||
|
||||
request_record: RequestRecord = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"timestamp": datetime.now(),
|
||||
"entity_id": entity_id,
|
||||
"executor": executor_name,
|
||||
"input": request_input,
|
||||
"model": model,
|
||||
"stream": True,
|
||||
}
|
||||
session["requests"].append(request_record)
|
||||
return request_record["id"]
|
||||
|
||||
def update_request_record(self, session_id: str, request_id: str, updates: dict[str, Any]) -> None:
|
||||
"""Update a request record in a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
request_id: Request ID to update
|
||||
updates: Dictionary of updates to apply
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
for request in session["requests"]:
|
||||
if request["id"] == request_id:
|
||||
request_data = cast(dict[str, Any], request)
|
||||
request_data.update(updates)
|
||||
break
|
||||
|
||||
def get_session_history(self, session_id: str) -> SessionSummary | None:
|
||||
"""Get session execution history.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Session history or None if not found
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"created_at": session["created_at"].isoformat(),
|
||||
"active": session["active"],
|
||||
"request_count": len(session["requests"]),
|
||||
"requests": [
|
||||
{
|
||||
"id": req["id"],
|
||||
"timestamp": req["timestamp"].isoformat(),
|
||||
"entity_id": req["entity_id"],
|
||||
"executor": req["executor"],
|
||||
"model": req["model"],
|
||||
"input_length": len(str(req["input"])) if req["input"] else 0,
|
||||
"execution_time": req.get("execution_time"),
|
||||
"status": req.get("status", "unknown"),
|
||||
}
|
||||
for req in session["requests"]
|
||||
],
|
||||
}
|
||||
|
||||
def get_active_sessions(self) -> list[SessionSummary]:
|
||||
"""Get list of active sessions.
|
||||
|
||||
Returns:
|
||||
List of active session summaries
|
||||
"""
|
||||
active_sessions: list[SessionSummary] = []
|
||||
|
||||
for session_id, session in self.sessions.items():
|
||||
if session["active"]:
|
||||
active_sessions.append({
|
||||
"session_id": session_id,
|
||||
"created_at": session["created_at"].isoformat(),
|
||||
"request_count": len(session["requests"]),
|
||||
"last_activity": (
|
||||
session["requests"][-1]["timestamp"].isoformat()
|
||||
if session["requests"]
|
||||
else session["created_at"].isoformat()
|
||||
),
|
||||
})
|
||||
|
||||
return active_sessions
|
||||
|
||||
def cleanup_old_sessions(self, max_age_hours: int = 24) -> None:
|
||||
"""Cleanup old sessions to prevent memory leaks.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age of sessions to keep in hours
|
||||
"""
|
||||
cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
|
||||
|
||||
sessions_to_remove: list[str] = []
|
||||
for session_id, session in self.sessions.items():
|
||||
if session["created_at"].timestamp() < cutoff_time:
|
||||
sessions_to_remove.append(session_id)
|
||||
|
||||
for session_id in sessions_to_remove:
|
||||
del self.sessions[session_id]
|
||||
logger.debug(f"Cleaned up old session: {session_id}")
|
||||
|
||||
if sessions_to_remove:
|
||||
logger.info(f"Cleaned up {len(sessions_to_remove)} old sessions")
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Simplified tracing integration for Agent Framework Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Generator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
||||
|
||||
from .models import ResponseTraceEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleTraceCollector(SpanExporter):
|
||||
"""Simple trace collector that captures spans for direct yielding."""
|
||||
|
||||
def __init__(self, response_id: str | None = None, entity_id: str | None = None) -> None:
|
||||
"""Initialize trace collector.
|
||||
|
||||
Args:
|
||||
response_id: Response identifier for grouping traces by turn
|
||||
entity_id: Entity identifier for context
|
||||
"""
|
||||
self.response_id = response_id
|
||||
self.entity_id = entity_id
|
||||
self.collected_events: list[ResponseTraceEvent] = []
|
||||
|
||||
def export(self, spans: Sequence[Any]) -> SpanExportResult:
|
||||
"""Collect spans as trace events.
|
||||
|
||||
Args:
|
||||
spans: Sequence of OpenTelemetry spans
|
||||
|
||||
Returns:
|
||||
SpanExportResult indicating success
|
||||
"""
|
||||
logger.debug(f"SimpleTraceCollector received {len(spans)} spans")
|
||||
|
||||
try:
|
||||
for span in spans:
|
||||
trace_event = self._convert_span_to_trace_event(span)
|
||||
if trace_event:
|
||||
self.collected_events.append(trace_event)
|
||||
logger.debug(f"Collected trace event: {span.name}")
|
||||
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting trace spans: {e}")
|
||||
return SpanExportResult.FAILURE
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
"""Force flush spans (no-op for simple collection)."""
|
||||
return True
|
||||
|
||||
def get_pending_events(self) -> list[ResponseTraceEvent]:
|
||||
"""Get and clear pending trace events.
|
||||
|
||||
Returns:
|
||||
List of collected trace events, clearing the internal list
|
||||
"""
|
||||
events = self.collected_events.copy()
|
||||
self.collected_events.clear()
|
||||
return events
|
||||
|
||||
def _convert_span_to_trace_event(self, span: Any) -> ResponseTraceEvent | None:
|
||||
"""Convert OpenTelemetry span to ResponseTraceEvent.
|
||||
|
||||
Args:
|
||||
span: OpenTelemetry span
|
||||
|
||||
Returns:
|
||||
ResponseTraceEvent or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
start_time = span.start_time / 1_000_000_000 # Convert from nanoseconds
|
||||
end_time = span.end_time / 1_000_000_000 if span.end_time else None
|
||||
duration_ms = ((end_time - start_time) * 1000) if end_time else None
|
||||
|
||||
# Build trace data
|
||||
trace_data = {
|
||||
"type": "trace_span",
|
||||
"span_id": str(span.context.span_id),
|
||||
"trace_id": str(span.context.trace_id),
|
||||
"parent_span_id": str(span.parent.span_id) if span.parent else None,
|
||||
"operation_name": span.name,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"duration_ms": duration_ms,
|
||||
"attributes": dict(span.attributes) if span.attributes else {},
|
||||
"status": str(span.status.status_code) if hasattr(span, "status") else "OK",
|
||||
"response_id": self.response_id,
|
||||
"entity_id": self.entity_id,
|
||||
}
|
||||
|
||||
# Add events if available
|
||||
if hasattr(span, "events") and span.events:
|
||||
trace_data["events"] = [
|
||||
{
|
||||
"name": event.name,
|
||||
"timestamp": event.timestamp / 1_000_000_000,
|
||||
"attributes": dict(event.attributes) if event.attributes else {},
|
||||
}
|
||||
for event in span.events
|
||||
]
|
||||
|
||||
# Add error information if span failed
|
||||
if hasattr(span, "status") and span.status.status_code.name == "ERROR":
|
||||
trace_data["error"] = span.status.description or "Unknown error"
|
||||
|
||||
return ResponseTraceEvent(type="trace_event", data=trace_data, timestamp=datetime.now().isoformat())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert span {getattr(span, 'name', 'unknown')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_traces(response_id: str | None = None, entity_id: str | None = None) -> Generator[SimpleTraceCollector]:
|
||||
"""Context manager to capture traces during execution.
|
||||
|
||||
Args:
|
||||
response_id: Response identifier for grouping traces by turn
|
||||
entity_id: Entity identifier for context
|
||||
|
||||
Yields:
|
||||
SimpleTraceCollector instance to get trace events from
|
||||
"""
|
||||
collector = SimpleTraceCollector(response_id, entity_id)
|
||||
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
|
||||
# Get current tracer provider and add our collector
|
||||
provider = trace.get_tracer_provider()
|
||||
processor = SimpleSpanProcessor(collector)
|
||||
|
||||
# Check if this is a real TracerProvider (not the default NoOpTracerProvider)
|
||||
if isinstance(provider, TracerProvider):
|
||||
provider.add_span_processor(processor)
|
||||
logger.debug(f"Added trace collector to TracerProvider for response: {response_id}, entity: {entity_id}")
|
||||
|
||||
try:
|
||||
yield collector
|
||||
finally:
|
||||
# Clean up - shutdown processor
|
||||
try:
|
||||
processor.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error shutting down processor: {e}")
|
||||
else:
|
||||
logger.warning(f"No real TracerProvider available, got: {type(provider)}")
|
||||
yield collector
|
||||
|
||||
except ImportError:
|
||||
logger.debug("OpenTelemetry not available")
|
||||
yield collector
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up trace capture: {e}")
|
||||
yield collector
|
||||
@@ -0,0 +1,801 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Utility functions for DevUI."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, Union, cast, get_args, get_origin, get_type_hints
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _string_key_dict(value: object) -> dict[str, Any] | None:
|
||||
"""Cast value to a dict."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], value)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Metadata Extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
|
||||
"""Extract agent-specific metadata from an entity object.
|
||||
|
||||
Args:
|
||||
entity_object: Agent Framework agent object
|
||||
|
||||
Returns:
|
||||
Dictionary with agent metadata: instructions, model, chat_client_type,
|
||||
context_providers, and middleware
|
||||
"""
|
||||
metadata = {
|
||||
"instructions": None,
|
||||
"model": None,
|
||||
"chat_client_type": None,
|
||||
"context_provider": None,
|
||||
"middleware": None,
|
||||
}
|
||||
|
||||
# Try to get instructions
|
||||
if hasattr(entity_object, "default_options"):
|
||||
chat_opts = entity_object.default_options
|
||||
chat_opts_dict = _string_key_dict(chat_opts)
|
||||
if chat_opts_dict is not None:
|
||||
if "instructions" in chat_opts_dict:
|
||||
metadata["instructions"] = chat_opts_dict.get("instructions")
|
||||
elif hasattr(chat_opts, "instructions"):
|
||||
metadata["instructions"] = chat_opts.instructions
|
||||
|
||||
# Try to get model - check both default_options and client
|
||||
if hasattr(entity_object, "default_options"):
|
||||
chat_opts = entity_object.default_options
|
||||
chat_opts_dict = _string_key_dict(chat_opts)
|
||||
if chat_opts_dict is not None:
|
||||
model = chat_opts_dict.get("model")
|
||||
if model:
|
||||
metadata["model"] = model
|
||||
elif hasattr(chat_opts, "model") and chat_opts.model:
|
||||
metadata["model"] = chat_opts.model
|
||||
if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model"):
|
||||
metadata["model"] = entity_object.client.model
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(entity_object, "client"):
|
||||
metadata["chat_client_type"] = entity_object.client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(entity_object, "context_provider")
|
||||
and entity_object.context_provider
|
||||
and hasattr(entity_object.context_provider, "__class__")
|
||||
):
|
||||
metadata["context_provider"] = [entity_object.context_provider.__class__.__name__] # type: ignore
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(entity_object, "middleware") and entity_object.middleware:
|
||||
middlewares_list: list[str] = []
|
||||
for m in entity_object.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middlewares_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middlewares_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middlewares_list.append(str(m))
|
||||
metadata["middleware"] = middlewares_list # type: ignore
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Input Type Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_executor_message_types(executor: Any) -> list[Any]:
|
||||
"""Extract declared input types for the given executor.
|
||||
|
||||
Args:
|
||||
executor: Workflow executor object
|
||||
|
||||
Returns:
|
||||
List of message types that the executor accepts
|
||||
"""
|
||||
message_types: list[Any] = []
|
||||
|
||||
try:
|
||||
input_types = getattr(executor, "input_types", None)
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to access executor input_types: {exc}")
|
||||
else:
|
||||
if input_types:
|
||||
message_types = list(input_types)
|
||||
|
||||
if not message_types and hasattr(executor, "_handlers"):
|
||||
try:
|
||||
handlers = executor._handlers
|
||||
if isinstance(handlers, dict):
|
||||
message_types = list(handlers.keys()) # type: ignore[arg-type]
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to read executor handlers: {exc}")
|
||||
|
||||
return message_types
|
||||
|
||||
|
||||
def _contains_chat_message(type_hint: Any) -> bool:
|
||||
"""Check whether the provided type hint directly or indirectly references Message."""
|
||||
if type_hint is Message:
|
||||
return True
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
if origin in (list, tuple):
|
||||
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
|
||||
|
||||
if origin in (Union, UnionType):
|
||||
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_list_message_type(type_hint: Any) -> bool:
|
||||
"""Return True if type_hint is exactly list[Message]."""
|
||||
return get_origin(type_hint) is list and bool(get_args(type_hint)) and get_args(type_hint)[0] is Message
|
||||
|
||||
|
||||
def _find_chat_message_type(type_hint: Any) -> Any | None:
|
||||
"""Return ``list[Message]`` or ``Message`` if present in type_hint, else None.
|
||||
|
||||
Recursively inspects union members so that a single-element ``message_types``
|
||||
list like ``[dict | str | list[Message] | ...]`` (the real ``JoinExecutor``
|
||||
form) is handled correctly. ``list[Message]`` takes priority over bare
|
||||
``Message``.
|
||||
"""
|
||||
if _is_list_message_type(type_hint):
|
||||
return type_hint
|
||||
if type_hint is Message:
|
||||
return Message
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
if origin in (Union, UnionType):
|
||||
fallback = None
|
||||
for arg in get_args(type_hint):
|
||||
found = _find_chat_message_type(arg)
|
||||
if found is None:
|
||||
continue
|
||||
if _is_list_message_type(found):
|
||||
return found # list[Message] wins immediately
|
||||
if fallback is None:
|
||||
fallback = found # bare Message — keep searching
|
||||
return fallback
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def select_primary_input_type(message_types: list[Any]) -> Any | None:
|
||||
"""Choose the most user-friendly input type for workflow inputs.
|
||||
|
||||
Prefers Message (or containers thereof) and then falls back to primitives.
|
||||
When the executor's union contains ``list[Message]`` (as the declarative
|
||||
entry ``JoinExecutor`` does), that type is returned so that
|
||||
``parse_input_for_type`` can wrap the user's text in a list before
|
||||
dispatching — avoiding a "cannot handle message of type Message" error.
|
||||
|
||||
``message_types`` may contain full union types as single elements (the real
|
||||
``JoinExecutor`` emits ``[dict | str | list[Message] | ...]``), so each
|
||||
element is searched recursively.
|
||||
|
||||
Args:
|
||||
message_types: List of possible message types
|
||||
|
||||
Returns:
|
||||
Selected primary input type, or None if list is empty
|
||||
"""
|
||||
if not message_types:
|
||||
return None
|
||||
|
||||
# First pass: search each type (including union members) for list[Message] or Message.
|
||||
for message_type in message_types:
|
||||
found = _find_chat_message_type(message_type)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
# Second pass: broader fallback for deeply nested or unusual containers.
|
||||
for message_type in message_types:
|
||||
if _contains_chat_message(message_type):
|
||||
return Message
|
||||
|
||||
preferred = (str, dict)
|
||||
|
||||
for candidate in preferred:
|
||||
for message_type in message_types:
|
||||
if message_type is candidate:
|
||||
return candidate
|
||||
origin = get_origin(message_type)
|
||||
if origin is candidate:
|
||||
return candidate
|
||||
|
||||
return message_types[0]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Type System Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def is_serialization_mixin(cls: type) -> bool:
|
||||
"""Check if class is a SerializationMixin subclass.
|
||||
|
||||
Args:
|
||||
cls: Class to check
|
||||
|
||||
Returns:
|
||||
True if class is a SerializationMixin subclass
|
||||
"""
|
||||
try:
|
||||
from agent_framework._serialization import SerializationMixin
|
||||
|
||||
return isinstance(cls, type) and issubclass(cls, SerializationMixin)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _type_to_schema(type_hint: Any, field_name: str) -> dict[str, Any]:
|
||||
"""Convert a type hint to JSON schema.
|
||||
|
||||
Args:
|
||||
type_hint: Type hint to convert
|
||||
field_name: Name of the field (for documentation)
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
type_str = str(type_hint)
|
||||
|
||||
# Handle None/Optional
|
||||
if type_hint is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# Handle basic types
|
||||
if type_hint is str or "str" in type_str:
|
||||
return {"type": "string"}
|
||||
if type_hint is int or "int" in type_str:
|
||||
return {"type": "integer"}
|
||||
if type_hint is float or "float" in type_str:
|
||||
return {"type": "number"}
|
||||
if type_hint is bool or "bool" in type_str:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# Handle Literal types (for enum-like values)
|
||||
if "Literal" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
return {"type": "string", "enum": list(args)}
|
||||
|
||||
# Handle Union/Optional
|
||||
if "Union" in type_str or "Optional" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
# Filter out None type
|
||||
non_none_args = [arg for arg in args if arg is not type(None)]
|
||||
if len(non_none_args) == 1:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
# Multiple types - pick first non-None
|
||||
if non_none_args:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
|
||||
# Handle collections
|
||||
if "list" in type_str or "List" in type_str or "Sequence" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
items_schema = _type_to_schema(args[0], field_name)
|
||||
return {"type": "array", "items": items_schema}
|
||||
return {"type": "array"}
|
||||
|
||||
if "dict" in type_str or "Dict" in type_str or "Mapping" in type_str:
|
||||
return {"type": "object"}
|
||||
|
||||
# Default fallback
|
||||
return {"type": "string", "description": f"Type: {type_hint}"}
|
||||
|
||||
|
||||
def generate_schema_from_serialization_mixin(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from SerializationMixin class.
|
||||
|
||||
Introspects the __init__ signature to extract parameter types and defaults.
|
||||
|
||||
Args:
|
||||
cls: SerializationMixin subclass
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
sig = inspect.signature(cls)
|
||||
|
||||
# Get type hints
|
||||
try:
|
||||
type_hints = get_type_hints(cls)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ("self", "kwargs"):
|
||||
continue
|
||||
|
||||
# Get type annotation
|
||||
param_type = type_hints.get(param_name, str)
|
||||
|
||||
# Generate schema for this parameter
|
||||
param_schema = _type_to_schema(param_type, param_name)
|
||||
properties[param_name] = param_schema
|
||||
|
||||
# Check if required (no default value, not VAR_KEYWORD)
|
||||
if param.default == inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
|
||||
required.append(param_name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from dataclass.
|
||||
|
||||
Args:
|
||||
cls: Dataclass type
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
if not is_dataclass(cls):
|
||||
return {"type": "object"}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for field in fields(cls):
|
||||
# Generate schema for field type
|
||||
field_schema = _type_to_schema(field.type, field.name)
|
||||
properties[field.name] = field_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if field.default == field.default_factory: # No default
|
||||
required.append(field.name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def extract_response_type_from_executor(executor: Any, request_type: type) -> type | None:
|
||||
"""Extract the expected response type from an executor's response handler.
|
||||
|
||||
Looks for methods decorated with @response_handler that have signature:
|
||||
async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
|
||||
Args:
|
||||
executor: Executor object that should have a handler for the request type
|
||||
request_type: The request message type
|
||||
|
||||
Returns:
|
||||
The response type class, or None if not found
|
||||
"""
|
||||
try:
|
||||
# Introspect handler methods for @response_handler pattern
|
||||
for attr_name in dir(executor):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
attr = getattr(executor, attr_name, None)
|
||||
if not callable(attr):
|
||||
continue
|
||||
|
||||
# Get type hints for this method
|
||||
try:
|
||||
type_hints = get_type_hints(attr)
|
||||
|
||||
# Check for @response_handler pattern:
|
||||
# async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
type_hint_params = {k: v for k, v in type_hints.items() if k not in ("self", "return")}
|
||||
|
||||
# Look for at least 2 parameters: original_request, response (ctx is optional)
|
||||
if len(type_hint_params) >= 2:
|
||||
param_items = list(type_hint_params.items())
|
||||
# First param should be original_request matching request_type
|
||||
_, first_param_type = param_items[0]
|
||||
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
|
||||
|
||||
# Check if first param matches request_type
|
||||
first_matches_request = first_param_type == request_type
|
||||
if not first_matches_request and isinstance(first_param_type, type):
|
||||
request_type_name = request_type.__name__
|
||||
first_matches_request = first_param_type.__name__ == request_type_name
|
||||
|
||||
# Verify we have a matching request type and valid response type (must be a type class)
|
||||
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
|
||||
response_type_class: type = second_param_type
|
||||
logger.debug(
|
||||
f"Found response type {response_type_class} for request {request_type} "
|
||||
f"via @response_handler"
|
||||
)
|
||||
return response_type_class
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get type hints for {attr_name}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract response type from executor: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_input_schema(input_type: type) -> dict[str, Any]:
|
||||
"""Generate JSON schema for workflow input type.
|
||||
|
||||
Supports multiple input types in priority order:
|
||||
0. list[Message] — rendered as a plain string input (DevUI presents a text
|
||||
box; the text is later wrapped in a list by parse_input_for_type)
|
||||
1. Built-in types (str, dict, int, etc.)
|
||||
2. Pydantic models (via model_json_schema)
|
||||
3. SerializationMixin classes (via __init__ introspection)
|
||||
4. Dataclasses (via fields introspection)
|
||||
5. Fallback to string
|
||||
|
||||
Args:
|
||||
input_type: Input type to generate schema for
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
# 0. list[Message] — treat as simple text so DevUI shows a text box
|
||||
if _is_list_message_type(input_type):
|
||||
return {"type": "string"}
|
||||
|
||||
# 1. Built-in types
|
||||
if input_type is str:
|
||||
return {"type": "string"}
|
||||
if input_type is dict:
|
||||
return {"type": "object"}
|
||||
if input_type is int:
|
||||
return {"type": "integer"}
|
||||
if input_type is float:
|
||||
return {"type": "number"}
|
||||
if input_type is bool:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# 2. Pydantic models (legacy support)
|
||||
if hasattr(input_type, "model_json_schema"):
|
||||
return input_type.model_json_schema() # type: ignore
|
||||
|
||||
# 3. SerializationMixin classes (Message, etc.)
|
||||
if is_serialization_mixin(input_type):
|
||||
return generate_schema_from_serialization_mixin(input_type)
|
||||
|
||||
# 4. Dataclasses
|
||||
if is_dataclass(input_type):
|
||||
return generate_schema_from_dataclass(input_type)
|
||||
|
||||
# 5. Fallback to string
|
||||
type_name = input_type.__name__ if isinstance(input_type, type) else str(cast(Any, input_type))
|
||||
return {"type": "string", "description": f"Input type: {type_name}"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Input Parsing Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_input_for_type(input_data: Any, target_type: type) -> Any:
|
||||
"""Parse input data to match the target type.
|
||||
|
||||
Handles conversion from raw input (string, dict) to the expected type:
|
||||
- list[Message]: build a Message from the raw input and wrap it in a list
|
||||
- Built-in types: direct conversion
|
||||
- Pydantic models: use model_validate or model_validate_json
|
||||
- SerializationMixin: use from_dict or construct from string
|
||||
- Dataclasses: construct from dict
|
||||
|
||||
Args:
|
||||
input_data: Raw input data (string, dict, or already correct type)
|
||||
target_type: Expected type for the input
|
||||
|
||||
Returns:
|
||||
Parsed input matching target_type, or original input if parsing fails
|
||||
"""
|
||||
# list[Message]: generic aliases cannot be used with isinstance, handle first.
|
||||
if _is_list_message_type(target_type):
|
||||
raw: object = input_data
|
||||
if isinstance(raw, list):
|
||||
items = cast(list[object], raw)
|
||||
if all(isinstance(m, Message) for m in items):
|
||||
return items
|
||||
# Try to convert each item (serialized str/dict OpenAI message) to Message.
|
||||
converted: list[Message] = []
|
||||
ok = True
|
||||
for item in items:
|
||||
if isinstance(item, Message):
|
||||
converted.append(item)
|
||||
elif isinstance(item, str):
|
||||
converted.append(_build_message_from_legacy_payload(item))
|
||||
elif isinstance(item, dict):
|
||||
converted.append(_build_message_from_legacy_payload(cast(dict[str, Any], item)))
|
||||
else:
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return converted
|
||||
# A list never matches the Message/str/dict checks below; stringify directly.
|
||||
return [_build_message_from_legacy_payload(str(items))]
|
||||
if isinstance(raw, Message):
|
||||
return [raw]
|
||||
if isinstance(raw, str):
|
||||
return [_build_message_from_legacy_payload(raw)]
|
||||
parsed_dict = _string_key_dict(raw)
|
||||
if parsed_dict is not None:
|
||||
if parsed_dict and _looks_like_message_dict(parsed_dict):
|
||||
return [_build_message_from_legacy_payload(parsed_dict)]
|
||||
return raw
|
||||
return [_build_message_from_legacy_payload(str(raw))]
|
||||
|
||||
# If already correct type, return as-is
|
||||
if isinstance(input_data, target_type):
|
||||
return input_data
|
||||
|
||||
# Handle string input
|
||||
if isinstance(input_data, str):
|
||||
return _parse_string_input(input_data, target_type)
|
||||
|
||||
# Handle dict input
|
||||
parsed_dict = _string_key_dict(input_data)
|
||||
if parsed_dict is not None:
|
||||
return _parse_dict_input(parsed_dict, target_type)
|
||||
|
||||
# Fallback: return original
|
||||
return input_data
|
||||
|
||||
|
||||
def _looks_like_message_dict(d: dict[str, Any]) -> bool:
|
||||
"""Return True if *d* is a recognisable serialised Message payload.
|
||||
|
||||
Three recognised signatures (in priority order):
|
||||
1. ``type`` discriminator is literally ``"message"`` — output of ``Message.to_dict()``.
|
||||
2. Dict has a ``"role"`` key — all framework Message objects carry a role.
|
||||
3. Dict has exactly the single key ``"input"`` — DevUI ``WorkflowInputForm``
|
||||
submits ``{"input": "<user text>"}`` for workflows whose schema is ``{"type":
|
||||
"string"}``.
|
||||
|
||||
Anything else is treated as a structured workflow input and passed through
|
||||
unchanged, to be handled by the dict-union branch of the executor.
|
||||
"""
|
||||
if d.get("type") == "message":
|
||||
return True
|
||||
if "role" in d:
|
||||
return True
|
||||
return set(d.keys()) == {"input"}
|
||||
|
||||
|
||||
def _build_message_from_legacy_payload(input_data: str | dict[str, Any]) -> Message:
|
||||
"""Convert raw DevUI input into a framework Message.
|
||||
|
||||
This preserves DevUI compatibility for older payloads that still send
|
||||
``{"role": "...", "text": "..."}`` instead of the framework-native
|
||||
``{"role": "...", "contents": [...]}`` shape.
|
||||
"""
|
||||
if isinstance(input_data, str):
|
||||
return Message(role="user", contents=[input_data])
|
||||
|
||||
role = input_data.get("role", "user")
|
||||
role = role if isinstance(role, str) else str(role)
|
||||
|
||||
if "contents" in input_data:
|
||||
contents = input_data["contents"]
|
||||
else:
|
||||
contents = None
|
||||
for field in ("text", "message", "content", "input", "data"):
|
||||
if field in input_data:
|
||||
contents = input_data[field]
|
||||
break
|
||||
|
||||
if contents is None:
|
||||
contents_list: list[Any] = []
|
||||
elif isinstance(contents, list):
|
||||
contents_list = contents # type: ignore[reportUnknownVariableType]
|
||||
else:
|
||||
contents_list = [contents]
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
for field in (
|
||||
"author_name",
|
||||
"message_id",
|
||||
"additional_properties",
|
||||
"raw_representation",
|
||||
):
|
||||
if field in input_data:
|
||||
kwargs[field] = input_data[field]
|
||||
|
||||
return Message(role=role, contents=contents_list, **kwargs)
|
||||
|
||||
|
||||
def _parse_string_input(input_str: str, target_type: type) -> Any:
|
||||
"""Parse string input to target type.
|
||||
|
||||
Args:
|
||||
input_str: Input string
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original string
|
||||
"""
|
||||
# Built-in types
|
||||
if target_type is str:
|
||||
return input_str
|
||||
if target_type is int:
|
||||
try:
|
||||
return int(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is float:
|
||||
try:
|
||||
return float(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is bool:
|
||||
return input_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate_json"):
|
||||
try:
|
||||
# Try parsing as JSON first
|
||||
if input_str.strip().startswith("{"):
|
||||
return target_type.model_validate_json(input_str) # type: ignore
|
||||
|
||||
# Try common field names with the string value
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string input with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin (like Message)
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
if target_type is Message:
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
parsed_dict = _string_key_dict(data)
|
||||
if parsed_dict is not None:
|
||||
return _build_message_from_legacy_payload(parsed_dict)
|
||||
return _build_message_from_legacy_payload(input_str)
|
||||
|
||||
# Try parsing as JSON dict first
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(data) # type: ignore
|
||||
return target_type(**data)
|
||||
|
||||
# Try other common fields
|
||||
common_fields = ["text", "message", "content"]
|
||||
sig = inspect.signature(target_type)
|
||||
params = list(sig.parameters.keys())
|
||||
for field in common_fields:
|
||||
if field in params:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create SerializationMixin with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
# Try parsing as JSON
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
return target_type(**data)
|
||||
|
||||
# Try common field names
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create dataclass with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as dataclass: {e}")
|
||||
|
||||
# Fallback: return original string
|
||||
return input_str
|
||||
|
||||
|
||||
def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
|
||||
"""Parse dict input to target type.
|
||||
|
||||
Args:
|
||||
input_dict: Input dictionary
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original dict
|
||||
"""
|
||||
# Handle primitive types - extract from common field names
|
||||
if target_type in (str, int, float, bool):
|
||||
try:
|
||||
# If it's already the right type, return as-is
|
||||
if isinstance(input_dict, target_type):
|
||||
return input_dict
|
||||
|
||||
# Try "input" field first (common for workflow inputs)
|
||||
if "input" in input_dict:
|
||||
return target_type(input_dict["input"])
|
||||
|
||||
# If single-key dict, extract the value
|
||||
if len(input_dict) == 1:
|
||||
value = next(iter(input_dict.values()))
|
||||
return target_type(value)
|
||||
|
||||
# Otherwise, return as-is
|
||||
return input_dict
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"Failed to convert dict to {target_type}: {e}")
|
||||
return input_dict
|
||||
|
||||
# If target is dict, return as-is
|
||||
if target_type is dict:
|
||||
return input_dict
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate"):
|
||||
try:
|
||||
return target_type.model_validate(input_dict) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
if target_type is Message:
|
||||
return _build_message_from_legacy_payload(input_dict)
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(input_dict) # type: ignore
|
||||
return target_type(**input_dict)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
return target_type(**input_dict)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as dataclass: {e}")
|
||||
|
||||
# Fallback: return original dict
|
||||
return input_dict
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework DevUI Models - OpenAI-compatible types and custom extensions."""
|
||||
|
||||
# Import discovery models
|
||||
# Import all OpenAI types directly from the openai package
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseReasoningTextDeltaEvent,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseUsage,
|
||||
ToolParam,
|
||||
)
|
||||
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
|
||||
from openai.types.shared import Metadata, ResponsesModel
|
||||
|
||||
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
|
||||
from ._openai_custom import (
|
||||
AgentFrameworkRequest,
|
||||
CustomResponseOutputItemAddedEvent,
|
||||
CustomResponseOutputItemDoneEvent,
|
||||
ExecutorActionItem,
|
||||
MetaResponse,
|
||||
OpenAIError,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseOutputData,
|
||||
ResponseOutputFile,
|
||||
ResponseOutputImage,
|
||||
ResponseTraceEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseWorkflowEventComplete,
|
||||
)
|
||||
|
||||
# Type alias for compatibility
|
||||
OpenAIResponse = Response
|
||||
|
||||
# Export all types for easy importing
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"Conversation",
|
||||
"ConversationDeletedResource",
|
||||
"ConversationItem",
|
||||
"CustomResponseOutputItemAddedEvent",
|
||||
"CustomResponseOutputItemDoneEvent",
|
||||
"Deployment",
|
||||
"DeploymentConfig",
|
||||
"DeploymentEvent",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"ExecutorActionItem",
|
||||
"InputTokensDetails",
|
||||
"MetaResponse",
|
||||
"Metadata",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"OutputTokensDetails",
|
||||
"Response",
|
||||
"ResponseCompletedEvent",
|
||||
"ResponseErrorEvent",
|
||||
"ResponseFunctionCallArgumentsDeltaEvent",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseFunctionToolCall",
|
||||
"ResponseFunctionToolCallOutputItem",
|
||||
"ResponseInputParam",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseOutputItemAddedEvent",
|
||||
"ResponseOutputItemDoneEvent",
|
||||
"ResponseOutputMessage",
|
||||
"ResponseOutputText",
|
||||
"ResponseReasoningTextDeltaEvent",
|
||||
"ResponseStreamEvent",
|
||||
"ResponseTextDeltaEvent",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseUsage",
|
||||
"ResponseWorkflowEventComplete",
|
||||
"ResponsesModel",
|
||||
"ToolParam",
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Discovery API models for entity information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class EnvVarRequirement(BaseModel):
|
||||
"""Environment variable requirement for an entity."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
required: bool = True
|
||||
example: str | None = None
|
||||
|
||||
|
||||
class EntityInfo(BaseModel):
|
||||
"""Entity information for discovery and detailed views."""
|
||||
|
||||
# Always present (core entity data)
|
||||
id: str
|
||||
type: str # "agent", "workflow"
|
||||
name: str
|
||||
description: str | None = None
|
||||
framework: str
|
||||
tools: list[str | dict[str, Any]] | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
# Source information
|
||||
source: str = "directory" # "directory" or "in_memory"
|
||||
|
||||
# Environment variable requirements
|
||||
required_env_vars: list[EnvVarRequirement] | None = None
|
||||
|
||||
# Deployment support
|
||||
deployment_supported: bool = False # Whether entity can be deployed
|
||||
deployment_reason: str | None = None # Explanation of why/why not entity can be deployed
|
||||
|
||||
# Agent-specific fields (optional, populated when available)
|
||||
instructions: str | None = None
|
||||
model: str | None = None
|
||||
chat_client_type: str | None = None
|
||||
context_provider: list[str] | None = None
|
||||
middleware: list[str] | None = None
|
||||
|
||||
# Workflow-specific fields (populated only for detailed info requests)
|
||||
executors: list[str] | None = None
|
||||
workflow_dump: dict[str, Any] | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
input_type_name: str | None = None
|
||||
start_executor_id: str | None = None
|
||||
|
||||
|
||||
class DiscoveryResponse(BaseModel):
|
||||
"""Response model for entity discovery."""
|
||||
|
||||
entities: list[EntityInfo] = Field(default_factory=cast(Callable[..., list[EntityInfo]], list))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class DeploymentConfig(BaseModel):
|
||||
"""Configuration for deploying an entity."""
|
||||
|
||||
entity_id: str = Field(description="Entity ID to deploy")
|
||||
resource_group: str = Field(description="Azure resource group name")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(default="eastus", description="Azure region")
|
||||
ui_mode: str = Field(default="user", description="UI mode (user or developer)")
|
||||
ui_enabled: bool = Field(default=True, description="Whether to enable web interface")
|
||||
stream: bool = Field(default=True, description="Stream deployment events")
|
||||
|
||||
@field_validator("app_name")
|
||||
@classmethod
|
||||
def validate_app_name(cls, v: str) -> str:
|
||||
"""Validate Azure Container App name format.
|
||||
|
||||
Azure Container App names must:
|
||||
- Be 3-32 characters long
|
||||
- Contain only lowercase letters, numbers, and hyphens
|
||||
- Start with a lowercase letter
|
||||
- End with a lowercase letter or number
|
||||
- Not contain consecutive hyphens
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("app_name cannot be empty")
|
||||
|
||||
if len(v) < 3 or len(v) > 32:
|
||||
raise ValueError("app_name must be between 3 and 32 characters")
|
||||
|
||||
if not re.match(r"^[a-z][a-z0-9-]*[a-z0-9]$", v):
|
||||
raise ValueError(
|
||||
"app_name must start with a lowercase letter, "
|
||||
"end with a letter or number, and contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
if "--" in v:
|
||||
raise ValueError("app_name cannot contain consecutive hyphens")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("resource_group")
|
||||
@classmethod
|
||||
def validate_resource_group(cls, v: str) -> str:
|
||||
"""Validate Azure resource group name format.
|
||||
|
||||
Azure resource group names must:
|
||||
- Be 1-90 characters long
|
||||
- Contain only alphanumeric, underscore, parentheses, hyphen, period (except at end)
|
||||
- Not end with a period
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("resource_group cannot be empty")
|
||||
|
||||
if len(v) > 90:
|
||||
raise ValueError("resource_group must be 90 characters or less")
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9._()-]+$", v):
|
||||
raise ValueError(
|
||||
"resource_group can only contain alphanumeric characters, "
|
||||
"underscores, hyphens, periods, and parentheses"
|
||||
)
|
||||
|
||||
if v.endswith("."):
|
||||
raise ValueError("resource_group cannot end with a period")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("region")
|
||||
@classmethod
|
||||
def validate_region(cls, v: str) -> str:
|
||||
"""Validate Azure region format.
|
||||
|
||||
Validates that the region string is a reasonable format.
|
||||
Does not validate against the full list of Azure regions (which changes).
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("region cannot be empty")
|
||||
|
||||
if len(v) > 50:
|
||||
raise ValueError("region name too long")
|
||||
|
||||
# Azure regions are typically lowercase with no spaces (e.g., eastus, westeurope)
|
||||
if not re.match(r"^[a-z0-9]+$", v):
|
||||
raise ValueError("region must contain only lowercase letters and numbers (e.g., eastus, westeurope)")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("entity_id")
|
||||
@classmethod
|
||||
def validate_entity_id(cls, v: str) -> str:
|
||||
"""Validate entity_id format to prevent injection attacks."""
|
||||
if not v:
|
||||
raise ValueError("entity_id cannot be empty")
|
||||
|
||||
if len(v) > 256:
|
||||
raise ValueError("entity_id too long")
|
||||
|
||||
# Allow alphanumeric, hyphens, underscores, and periods
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", v):
|
||||
raise ValueError("entity_id contains invalid characters")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("ui_mode")
|
||||
@classmethod
|
||||
def validate_ui_mode(cls, v: str) -> str:
|
||||
"""Validate ui_mode is one of the allowed values."""
|
||||
if v not in ("user", "developer"):
|
||||
raise ValueError("ui_mode must be 'user' or 'developer'")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class DeploymentEvent(BaseModel):
|
||||
"""Real-time deployment event (SSE)."""
|
||||
|
||||
type: str = Field(description="Event type (e.g., deploy.validating, deploy.building)")
|
||||
message: str = Field(description="Human-readable message")
|
||||
url: str | None = Field(default=None, description="Deployment URL (on completion)")
|
||||
auth_token: str | None = Field(default=None, description="Auth token (on completion, shown once)")
|
||||
|
||||
|
||||
class Deployment(BaseModel):
|
||||
"""Deployment record."""
|
||||
|
||||
id: str = Field(description="Deployment ID (UUID)")
|
||||
entity_id: str = Field(description="Entity ID that was deployed")
|
||||
resource_group: str = Field(description="Azure resource group")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(description="Azure region")
|
||||
url: str = Field(description="Deployment URL")
|
||||
status: str = Field(description="Deployment status (deploying, deployed, failed)")
|
||||
created_at: str = Field(description="ISO 8601 timestamp")
|
||||
error: str | None = Field(default=None, description="Error message if failed")
|
||||
@@ -0,0 +1,413 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Custom OpenAI-compatible event types for Agent Framework extensions.
|
||||
|
||||
These are custom event types that extend beyond the standard OpenAI Responses API
|
||||
to support Agent Framework specific features like workflows and traces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Custom Agent Framework OpenAI event types for structured data
|
||||
|
||||
|
||||
# Agent lifecycle events - simple and clear
|
||||
class AgentStartedEvent:
|
||||
"""Event emitted when an agent starts execution."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentCompletedEvent:
|
||||
"""Event emitted when an agent completes execution successfully."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentFailedEvent:
|
||||
"""Event emitted when an agent fails during execution."""
|
||||
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
class ExecutorActionItem(BaseModel):
|
||||
"""Custom item type for workflow executor actions.
|
||||
|
||||
This is a DevUI-specific extension to represent workflow executors as output items.
|
||||
Since OpenAI's ResponseOutputItemAddedEvent only accepts specific item types,
|
||||
and executor actions are not part of the standard, we need this custom type.
|
||||
"""
|
||||
|
||||
type: Literal["executor_action"] = "executor_action"
|
||||
id: str
|
||||
executor_id: str
|
||||
status: Literal["in_progress", "completed", "failed", "cancelled"] = "in_progress"
|
||||
metadata: dict[str, Any] | None = None
|
||||
result: Any | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CustomResponseOutputItemAddedEvent(BaseModel):
|
||||
"""Custom version of ResponseOutputItemAddedEvent that accepts any item type.
|
||||
|
||||
This allows us to emit executor action items while maintaining the same
|
||||
event structure as OpenAI's standard.
|
||||
"""
|
||||
|
||||
type: Literal["response.output_item.added"] = "response.output_item.added"
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class CustomResponseOutputItemDoneEvent(BaseModel):
|
||||
"""Custom version of ResponseOutputItemDoneEvent that accepts any item type.
|
||||
|
||||
This allows us to emit executor action items while maintaining the same
|
||||
event structure as OpenAI's standard.
|
||||
"""
|
||||
|
||||
type: Literal["response.output_item.done"] = "response.output_item.done"
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class ResponseWorkflowEventComplete(BaseModel):
|
||||
"""Complete workflow event data.
|
||||
|
||||
DevUI extension for workflow execution events (debugging/observability).
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern.
|
||||
|
||||
Workflow events are shown in the debug panel for monitoring execution flow,
|
||||
not in main chat. Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.workflow_event.completed"] = "response.workflow_event.completed"
|
||||
data: dict[str, Any] # Complete event data, not delta
|
||||
executor_id: str | None = None
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
|
||||
|
||||
class ResponseTraceEventComplete(BaseModel):
|
||||
"""Complete trace event data.
|
||||
|
||||
DevUI extension for non-displayable debugging/metadata events.
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern
|
||||
(e.g., response.completed, response.output_item.added).
|
||||
|
||||
Trace events are shown in the Traces debug panel, not in main chat.
|
||||
Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.trace.completed"] = "response.trace.completed"
|
||||
data: dict[str, Any] # Complete trace data, not delta
|
||||
span_id: str | None = None
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
|
||||
|
||||
class ResponseFunctionResultComplete(BaseModel):
|
||||
"""DevUI extension: Stream function execution results.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API doesn't stream function results (clients execute functions)
|
||||
- Agent Framework executes functions server-side, so we stream results for debugging visibility
|
||||
- ResponseFunctionToolCallOutputItem exists in OpenAI SDK but isn't in ResponseOutputItem union
|
||||
(it's for Conversations API input, not Responses API streaming output)
|
||||
|
||||
This event provides the same structure as OpenAI's function output items but wrapped
|
||||
in a custom event type since standard events don't support streaming function results.
|
||||
"""
|
||||
|
||||
type: Literal["response.function_result.complete"] = "response.function_result.complete"
|
||||
call_id: str
|
||||
output: str
|
||||
status: Literal["in_progress", "completed", "incomplete"]
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
timestamp: str | None = None # Optional timestamp for UI display
|
||||
|
||||
|
||||
class ResponseRequestInfoEvent(BaseModel):
|
||||
"""DevUI extension: Workflow requests human input.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API doesn't have a concept of workflow human-in-the-loop pausing
|
||||
- Agent Framework workflows can pause via RequestInfoExecutor to collect external information
|
||||
- Clients need to render forms and submit responses to continue workflow execution
|
||||
|
||||
When a workflow emits this event, it enters IDLE_WITH_PENDING_REQUESTS state.
|
||||
Client should render a form based on request_schema and submit responses via
|
||||
a new request with workflow_hil_response content type.
|
||||
"""
|
||||
|
||||
type: Literal["response.request_info.requested"] = "response.request_info.requested"
|
||||
request_id: str
|
||||
"""Unique identifier for correlating this request with the response."""
|
||||
|
||||
source_executor_id: str
|
||||
"""ID of the executor that is waiting for this response."""
|
||||
|
||||
request_type: str
|
||||
"""Fully qualified type name of the request (e.g., 'module.path:ClassName')."""
|
||||
|
||||
request_data: dict[str, Any]
|
||||
"""Current data from the RequestInfoMessage (may contain defaults/context)."""
|
||||
|
||||
request_schema: dict[str, Any]
|
||||
"""JSON schema describing the request data structure (what the workflow is asking about)."""
|
||||
|
||||
response_schema: dict[str, Any] | None = None
|
||||
"""JSON schema describing the expected response structure for form rendering (what user should provide)."""
|
||||
|
||||
item_id: str
|
||||
"""OpenAI item ID for correlation."""
|
||||
|
||||
output_index: int = 0
|
||||
"""Output index for OpenAI compatibility."""
|
||||
|
||||
sequence_number: int
|
||||
"""Sequence number for ordering events."""
|
||||
|
||||
timestamp: str
|
||||
"""ISO timestamp when the request was made."""
|
||||
|
||||
|
||||
# DevUI Output Content Types - for agent-generated media/data
|
||||
# These extend ResponseOutputItem to support rich content outputs that OpenAI's API doesn't natively support
|
||||
|
||||
|
||||
class ResponseOutputImage(BaseModel):
|
||||
"""DevUI extension: Agent-generated image output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- ImageGenerationCall exists but is for tool calls (generating images), not returning existing images
|
||||
- Agent Framework agents can return images via DataContent/UriContent that need proper display
|
||||
|
||||
This type allows images to be displayed inline in chat rather than hidden in trace logs.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the image output."""
|
||||
|
||||
image_url: str
|
||||
"""The URL or data URI of the image (e.g., data:image/png;base64,...)"""
|
||||
|
||||
type: Literal["output_image"] = "output_image"
|
||||
"""The type of the output. Always `output_image`."""
|
||||
|
||||
alt_text: str | None = None
|
||||
"""Optional alt text for accessibility."""
|
||||
|
||||
mime_type: str = "image/png"
|
||||
"""The MIME type of the image (e.g., image/png, image/jpeg)."""
|
||||
|
||||
|
||||
class ResponseOutputFile(BaseModel):
|
||||
"""DevUI extension: Agent-generated file output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return files via DataContent/UriContent that need proper display
|
||||
- Supports PDFs, audio files, and other media types
|
||||
|
||||
This type allows files to be displayed inline in chat with appropriate renderers.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the file output."""
|
||||
|
||||
filename: str
|
||||
"""The filename (used to determine rendering and download)."""
|
||||
|
||||
type: Literal["output_file"] = "output_file"
|
||||
"""The type of the output. Always `output_file`."""
|
||||
|
||||
file_url: str | None = None
|
||||
"""Optional URL to the file."""
|
||||
|
||||
file_data: str | None = None
|
||||
"""Optional base64-encoded file data."""
|
||||
|
||||
mime_type: str = "application/octet-stream"
|
||||
"""The MIME type of the file (e.g., application/pdf, audio/mp3)."""
|
||||
|
||||
|
||||
class ResponseOutputData(BaseModel):
|
||||
"""DevUI extension: Agent-generated generic data output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return arbitrary structured data that needs display
|
||||
- Useful for debugging and displaying non-text content
|
||||
|
||||
This type allows generic data to be displayed inline in chat.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the data output."""
|
||||
|
||||
data: str
|
||||
"""The data payload (string representation)."""
|
||||
|
||||
type: Literal["output_data"] = "output_data"
|
||||
"""The type of the output. Always `output_data`."""
|
||||
|
||||
mime_type: str
|
||||
"""The MIME type of the data."""
|
||||
|
||||
description: str | None = None
|
||||
"""Optional description of the data."""
|
||||
|
||||
|
||||
# Agent Framework extension fields
|
||||
class AgentFrameworkExtraBody(BaseModel):
|
||||
"""Agent Framework specific routing fields for OpenAI requests."""
|
||||
|
||||
entity_id: str
|
||||
# input_data removed - now using standard input field for all data
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
# Agent Framework Request Model - Extending real OpenAI types
|
||||
class AgentFrameworkRequest(BaseModel):
|
||||
"""OpenAI ResponseCreateParams with Agent Framework routing.
|
||||
|
||||
This properly extends the real OpenAI API request format.
|
||||
- Uses 'model' field as entity_id (agent/workflow name)
|
||||
- Uses 'conversation' field for conversation context (OpenAI standard)
|
||||
"""
|
||||
|
||||
# All OpenAI fields from ResponseCreateParams
|
||||
model: str | None = None
|
||||
input: str | list[Any] | dict[str, Any] # ResponseInputParam + dict for workflow structured input
|
||||
stream: bool | None = False
|
||||
|
||||
# OpenAI conversation parameter (standard!)
|
||||
conversation: str | dict[str, Any] | None = None # Union[str, {"id": str}]
|
||||
|
||||
# Common OpenAI optional fields
|
||||
instructions: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
temperature: float | None = None
|
||||
max_output_tokens: int | None = None
|
||||
top_p: float | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
# Reasoning parameters (for o-series models)
|
||||
reasoning: dict[str, Any] | None = None # {"effort": "low" | "medium" | "high" | "minimal"}
|
||||
|
||||
# Optional extra_body for advanced use cases
|
||||
extra_body: dict[str, Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
def get_entity_id(self) -> str | None:
|
||||
"""Get entity_id from metadata.entity_id.
|
||||
|
||||
In DevUI, entity_id is specified in metadata for routing.
|
||||
"""
|
||||
if self.metadata:
|
||||
return self.metadata.get("entity_id")
|
||||
return None
|
||||
|
||||
def _get_conversation_id(self) -> str | None:
|
||||
"""Extract conversation_id from conversation parameter.
|
||||
|
||||
Supports both string and object forms:
|
||||
- conversation: "conv_123"
|
||||
- conversation: {"id": "conv_123"}
|
||||
"""
|
||||
if isinstance(self.conversation, str):
|
||||
return self.conversation
|
||||
if isinstance(self.conversation, dict):
|
||||
return self.conversation.get("id")
|
||||
return None
|
||||
|
||||
def to_openai_params(self) -> dict[str, Any]:
|
||||
"""Convert to dict for OpenAI client compatibility."""
|
||||
return self.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
# Error handling
|
||||
class ResponseTraceEvent(BaseModel):
|
||||
"""Trace event for execution tracing."""
|
||||
|
||||
type: Literal["trace_event"] = "trace_event"
|
||||
data: dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
class OpenAIError(BaseModel):
|
||||
"""OpenAI standard error response model."""
|
||||
|
||||
error: dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> OpenAIError:
|
||||
"""Create a standard OpenAI error response."""
|
||||
error_data = {"message": message, "type": type, "code": code}
|
||||
return cls(error=error_data)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return the error payload as a plain mapping."""
|
||||
return {"error": dict(self.error)}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Return the error payload serialized to JSON."""
|
||||
return self.model_dump_json()
|
||||
|
||||
|
||||
class MetaResponse(BaseModel):
|
||||
"""Server metadata response for /meta endpoint.
|
||||
|
||||
Provides information about the DevUI server configuration and capabilities.
|
||||
"""
|
||||
|
||||
ui_mode: Literal["developer", "user"] = "developer"
|
||||
"""UI interface mode - 'developer' shows debug tools, 'user' shows simplified interface."""
|
||||
|
||||
version: str
|
||||
"""DevUI version string."""
|
||||
|
||||
framework: str = "agent_framework"
|
||||
"""Backend framework identifier."""
|
||||
|
||||
runtime: Literal["python", "dotnet"] = "python"
|
||||
"""Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability."""
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
"""Server capabilities (e.g., instrumentation, openai_proxy)."""
|
||||
|
||||
auth_required: bool = False
|
||||
"""Whether the server requires Bearer token authentication."""
|
||||
|
||||
|
||||
# Export all custom types
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"MetaResponse",
|
||||
"OpenAIError",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseWorkflowEventComplete",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_iii_510_1294)">
|
||||
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="8.39647" dy="8.39647"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="50"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D59FFF"/>
|
||||
<stop offset="1" stop-color="#8562C5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="./assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
# Testing DevUI - Quick Setup Guide
|
||||
|
||||
Here are the step-by-step instructions to test the new DevUI feature:
|
||||
|
||||
## 1. Get the Code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/microsoft/agent-framework.git
|
||||
cd agent-framework
|
||||
```
|
||||
|
||||
## 2. Setup Environment
|
||||
|
||||
Navigate to the Python directory and install dependencies:
|
||||
|
||||
```bash
|
||||
cd python
|
||||
uv sync --dev
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
## 3. Configure Environment Variables
|
||||
|
||||
Create a `.env` file in the `python/` directory with your API credentials:
|
||||
|
||||
```bash
|
||||
# Copy the example file
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then edit `.env` and add your API keys:
|
||||
|
||||
```bash
|
||||
# For OpenAI (minimum required)
|
||||
OPENAI_API_KEY="your-api-key-here"
|
||||
OPENAI_CHAT_COMPLETION_MODEL="gpt-4o-mini"
|
||||
|
||||
# Or for Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT="your-endpoint"
|
||||
AZURE_OPENAI_MODEL="your-deployment-name"
|
||||
```
|
||||
|
||||
## 4. Test DevUI
|
||||
|
||||
**Option A: In-Memory Mode (Recommended for quick testing)**
|
||||
|
||||
```bash
|
||||
cd samples/02-agents/devui
|
||||
python in_memory_mode.py
|
||||
```
|
||||
|
||||
This runs a simple example with predefined agents and opens your browser automatically at http://localhost:8090
|
||||
|
||||
**Option B: Directory-Based Discovery**
|
||||
|
||||
```bash
|
||||
cd samples/02-agents/devui
|
||||
devui
|
||||
```
|
||||
|
||||
This launches the UI with all example agents/workflows at http://localhost:8080
|
||||
|
||||
DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as
|
||||
`Authorization: Bearer <token>` for direct API calls. Use `--no-auth` only for loopback-only local testing.
|
||||
|
||||
## 5. What You'll See
|
||||
|
||||
- A web interface for testing agents interactively
|
||||
- Multiple example agents (weather assistant, general assistant, etc.)
|
||||
- OpenAI-compatible API endpoints for programmatic access
|
||||
|
||||
## 6. API Testing (Optional)
|
||||
|
||||
You can also test via API calls:
|
||||
|
||||
### Single Request
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "weather_agent",
|
||||
"input": "What is the weather in Seattle?"
|
||||
}'
|
||||
```
|
||||
|
||||
### Multi-turn Conversations
|
||||
|
||||
```bash
|
||||
# Create a conversation
|
||||
curl -X POST http://localhost:8080/v1/conversations \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"metadata": {"agent_id": "weather_agent"}}'
|
||||
|
||||
# Returns: {"id": "conv_abc123", ...}
|
||||
|
||||
# Use conversation ID in requests
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "weather_agent",
|
||||
"input": "What is the weather in Seattle?",
|
||||
"conversation": "conv_abc123"
|
||||
}'
|
||||
|
||||
# Continue the conversation
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Authorization: Bearer <devui-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "weather_agent",
|
||||
"input": "How about tomorrow?",
|
||||
"conversation": "conv_abc123"
|
||||
}'
|
||||
```
|
||||
|
||||
## API Mapping
|
||||
|
||||
Agent Framework content types → OpenAI Responses API events (in `_mapper.py`):
|
||||
|
||||
| Agent Framework Content | OpenAI Event | Status |
|
||||
| ------------------------------- | ---------------------------------------- | -------- |
|
||||
| `TextContent` | `response.output_text.delta` | Standard |
|
||||
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
|
||||
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
|
||||
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
|
||||
| `FunctionResultContent` | `response.function_result.complete` | DevUI |
|
||||
| `ErrorContent` | `response.error` | Standard |
|
||||
| `UsageContent` | `response.usage.complete` | Extended |
|
||||
| `WorkflowEvent` | `response.workflow.event` | DevUI |
|
||||
| `DataContent`, `UriContent` | `response.trace.complete` | DevUI |
|
||||
|
||||
- **Standard** = OpenAI spec, **Extended** = OpenAI + extra fields, **DevUI** = DevUI-specific
|
||||
|
||||
## Frontend Development
|
||||
|
||||
```bash
|
||||
cd python/packages/devui/frontend
|
||||
yarn install
|
||||
|
||||
# Development (hot reload)
|
||||
yarn dev
|
||||
|
||||
# Build (copies to backend ui/)
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd python/packages/devui
|
||||
|
||||
# All tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Specific suites
|
||||
pytest tests/test_conversations.py -v # Conversation store
|
||||
pytest tests/test_server.py -v # API endpoints
|
||||
pytest tests/test_mapper.py -v # Event mapping
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI.
|
||||
- **Import errors**: Run `uv sync --dev` again to ensure all dependencies are installed
|
||||
- **Port conflicts**: DevUI uses ports 8080 and 8090 by default - close other services using these ports
|
||||
|
||||
Let me know if you run into any issues!
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,23 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
.env.*
|
||||
claude.md
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,81 @@
|
||||
# DevUI Frontend
|
||||
|
||||
## Build Instructions
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
yarn install
|
||||
|
||||
# Create .env.local with backend URL
|
||||
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
|
||||
|
||||
# Create .env.production (empty for relative URLs)
|
||||
echo '' > .env.production
|
||||
|
||||
# Development
|
||||
yarn dev
|
||||
|
||||
# Build (copies to backend)
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// Allow exporting constants alongside components in specific patterns
|
||||
// This is common for shadcn/ui components (buttonVariants) and form utilities
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true }
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_iii_510_1294)">
|
||||
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="8.39647" dy="8.39647"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="50"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D59FFF"/>
|
||||
<stop offset="1" stop-color="#8562C5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* DevUI App - Minimal orchestrator for agent/workflow interactions
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
|
||||
|
||||
export default function App() {
|
||||
// Local state for auth handling
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [isTestingToken, setIsTestingToken] = useState(false);
|
||||
const [authError, setAuthError] = useState("");
|
||||
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
|
||||
const lastBufferedDebugFlushAtRef = useRef(0);
|
||||
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
const workflows = useDevUIStore((state) => state.workflows);
|
||||
const entities = useDevUIStore((state) => state.entities);
|
||||
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
|
||||
const entityError = useDevUIStore((state) => state.entityError);
|
||||
|
||||
// OpenAI proxy mode
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// UI mode
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
|
||||
// Entity actions
|
||||
const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
|
||||
const setEntities = useDevUIStore((state) => state.setEntities);
|
||||
const selectEntity = useDevUIStore((state) => state.selectEntity);
|
||||
const updateAgent = useDevUIStore((state) => state.updateAgent);
|
||||
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
|
||||
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
|
||||
const setEntityError = useDevUIStore((state) => state.setEntityError);
|
||||
|
||||
// UI state from Zustand
|
||||
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
|
||||
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
|
||||
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
|
||||
const debugEvents = useDevUIStore((state) => state.debugEvents);
|
||||
const isResizing = useDevUIStore((state) => state.isResizing);
|
||||
|
||||
// UI actions
|
||||
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
|
||||
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
|
||||
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
|
||||
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
|
||||
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
|
||||
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
|
||||
|
||||
// Modal state
|
||||
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
|
||||
const showGallery = useDevUIStore((state) => state.showGallery);
|
||||
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
|
||||
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
|
||||
|
||||
// Modal actions
|
||||
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
|
||||
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
|
||||
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
|
||||
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
|
||||
|
||||
// Toast state and actions
|
||||
const toasts = useDevUIStore((state) => state.toasts);
|
||||
const addToast = useDevUIStore((state) => state.addToast);
|
||||
const removeToast = useDevUIStore((state) => state.removeToast);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Fetch server metadata first (ui_mode, capabilities, auth status)
|
||||
const meta = await apiClient.getMeta();
|
||||
|
||||
// Check if auth is required
|
||||
if (meta.auth_required) {
|
||||
setAuthRequired(true);
|
||||
|
||||
// If we don't have a token, stop here and show auth UI
|
||||
if (!apiClient.getAuthToken()) {
|
||||
setEntityError("UNAUTHORIZED");
|
||||
setIsLoadingEntities(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useDevUIStore.getState().setServerMeta({
|
||||
uiMode: meta.ui_mode,
|
||||
runtime: meta.runtime,
|
||||
capabilities: meta.capabilities,
|
||||
authRequired: meta.auth_required,
|
||||
version: meta.version,
|
||||
});
|
||||
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
|
||||
setEntities(allEntities);
|
||||
setAgents(agentList);
|
||||
setWorkflows(workflowList);
|
||||
|
||||
// Check if there's an entity_id in the URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const entityId = urlParams.get("entity_id");
|
||||
|
||||
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedEntity = allEntities.find((e) => e.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedEntity) {
|
||||
setShowEntityNotFoundToast(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedEntity) {
|
||||
// Use the first entity from the backend's original order
|
||||
// This respects the backend's intended display order
|
||||
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
|
||||
|
||||
// Update URL to match actual selected entity (or clear if none)
|
||||
if (selectedEntity) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", selectedEntity.id);
|
||||
window.history.replaceState({}, "", url);
|
||||
} else {
|
||||
// Clear entity_id if no entities available
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("entity_id");
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEntity) {
|
||||
selectEntity(selectedEntity);
|
||||
|
||||
// Load full info for the first entity immediately
|
||||
if (selectedEntity.metadata?.lazy_loaded === false) {
|
||||
try {
|
||||
if (selectedEntity.type === "agent") {
|
||||
const fullAgent = await apiClient.getAgentInfo(
|
||||
selectedEntity.id
|
||||
);
|
||||
updateAgent(fullAgent);
|
||||
} else {
|
||||
const fullWorkflow = await apiClient.getWorkflowInfo(
|
||||
selectedEntity.id
|
||||
);
|
||||
updateWorkflow(fullWorkflow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load full info for first entity ${selectedEntity.id}:`,
|
||||
error
|
||||
);
|
||||
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
addToast({
|
||||
type: "error",
|
||||
message: `Failed to load "${selectedEntity.id}": ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoadingEntities(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load agents/workflows:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
|
||||
|
||||
// Check if this is an auth error
|
||||
if (errorMessage === "UNAUTHORIZED") {
|
||||
setAuthRequired(true);
|
||||
}
|
||||
|
||||
setEntityError(errorMessage);
|
||||
setIsLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast, addToast, setEntities]);
|
||||
|
||||
// Handle auth token submission
|
||||
const handleAuthTokenSubmit = useCallback(async () => {
|
||||
if (!authToken.trim()) return;
|
||||
|
||||
setIsTestingToken(true);
|
||||
setAuthError("");
|
||||
|
||||
try {
|
||||
// Set token in API client (stores in localStorage)
|
||||
apiClient.setAuthToken(authToken.trim());
|
||||
|
||||
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
|
||||
await apiClient.getEntities();
|
||||
|
||||
// If successful, reload to initialize with new token
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Token is invalid - clear it and show error
|
||||
apiClient.clearAuthToken();
|
||||
setIsTestingToken(false);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
if (errorMsg === "UNAUTHORIZED") {
|
||||
setAuthError("Invalid token. Please check and try again.");
|
||||
} else {
|
||||
setAuthError(`Failed to connect: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
|
||||
useEffect(() => {
|
||||
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
|
||||
// Workflows don't work with OpenAI proxy - switch to first available agent
|
||||
const firstAgent = agents[0];
|
||||
if (firstAgent) {
|
||||
selectEntity(firstAgent);
|
||||
}
|
||||
}
|
||||
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = debugPanelWidth;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = startX - e.clientX; // Subtract because we're dragging from right
|
||||
const newWidth = Math.max(
|
||||
200,
|
||||
Math.min(window.innerWidth * 0.5, startWidth + deltaX)
|
||||
);
|
||||
setDebugPanelWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[debugPanelWidth]
|
||||
);
|
||||
|
||||
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
|
||||
const handleEntitySelect = useCallback(
|
||||
async (item: AgentInfo | WorkflowInfo) => {
|
||||
selectEntity(item); // This clears conversation state, debug events, and updates URL!
|
||||
|
||||
// If entity is sparse (not fully loaded), load full details
|
||||
if (item.metadata?.lazy_loaded === false) {
|
||||
try {
|
||||
if (item.type === "agent") {
|
||||
const fullAgent = await apiClient.getAgentInfo(item.id);
|
||||
updateAgent(fullAgent);
|
||||
} else {
|
||||
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
|
||||
updateWorkflow(fullWorkflow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load full info for ${item.id}:`, error);
|
||||
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
addToast({
|
||||
type: "error",
|
||||
message: `Failed to load "${item.id}": ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectEntity, updateAgent, updateWorkflow, addToast]
|
||||
);
|
||||
|
||||
const flushBufferedDebugText = useCallback(() => {
|
||||
const bufferedEvent = bufferedDebugTextRef.current;
|
||||
if (!bufferedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
bufferedDebugTextRef.current = null;
|
||||
lastBufferedDebugFlushAtRef.current = performance.now();
|
||||
addDebugEvent(bufferedEvent);
|
||||
}, [addDebugEvent]);
|
||||
|
||||
// Handle debug events from active view
|
||||
const handleDebugEvent = useCallback(
|
||||
(event: ExtendedResponseStreamEvent | "clear") => {
|
||||
if (event === "clear") {
|
||||
bufferedDebugTextRef.current = null;
|
||||
clearDebugEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "response.output_text.delta" &&
|
||||
"delta" in event &&
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0
|
||||
) {
|
||||
const bufferedEvent = bufferedDebugTextRef.current;
|
||||
const isSameOutput =
|
||||
bufferedEvent !== null &&
|
||||
bufferedEvent.item_id === event.item_id &&
|
||||
bufferedEvent.output_index === event.output_index &&
|
||||
bufferedEvent.content_index === event.content_index;
|
||||
|
||||
if (isSameOutput && bufferedEvent) {
|
||||
bufferedDebugTextRef.current = {
|
||||
...bufferedEvent,
|
||||
delta: bufferedEvent.delta + event.delta,
|
||||
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
|
||||
};
|
||||
} else {
|
||||
flushBufferedDebugText();
|
||||
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
|
||||
}
|
||||
|
||||
if (
|
||||
performance.now() - lastBufferedDebugFlushAtRef.current >=
|
||||
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
|
||||
) {
|
||||
flushBufferedDebugText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
flushBufferedDebugText();
|
||||
addDebugEvent(event);
|
||||
},
|
||||
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
|
||||
);
|
||||
|
||||
// Show loading state while initializing
|
||||
if (isLoadingEntities) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
{/* Top Bar - Skeleton */}
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<div className="w-64 h-9 bg-muted animate-pulse rounded-md" />
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
|
||||
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Loading Content */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium">Initializing DevUI...</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state if loading failed
|
||||
if (entityError) {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
|
||||
|
||||
// Extract port from the backend URL for the command suggestion
|
||||
let backendPort = "8080"; // default fallback
|
||||
try {
|
||||
if (currentBackendUrl) {
|
||||
const url = new URL(currentBackendUrl);
|
||||
backendPort = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, keep default
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
agents={[]}
|
||||
workflows={[]}
|
||||
entities={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
isLoading={false}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
|
||||
{/* Error Content */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-6 max-w-2xl">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-muted p-4 animate-pulse">
|
||||
{isAuthError ? (
|
||||
<Lock className="h-12 w-12 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base">
|
||||
{isAuthError
|
||||
? "This backend requires a bearer token to access."
|
||||
: "No worries! Just start the DevUI backend server and you'll be good to go."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth Input or Command Instructions */}
|
||||
{isAuthError ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Enter Authentication Token
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste token from server logs"
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isTestingToken) {
|
||||
handleAuthTokenSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isTestingToken}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSubmit}
|
||||
disabled={!authToken.trim() || isTestingToken}
|
||||
className="w-full"
|
||||
>
|
||||
{isTestingToken ? "Verifying..." : "Connect"}
|
||||
</Button>
|
||||
|
||||
{/* Error message */}
|
||||
{authError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 text-center">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Where do I find the token?
|
||||
</summary>
|
||||
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Look for this in your DevUI server startup logs:
|
||||
</p>
|
||||
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
|
||||
🔑 DEV TOKEN (localhost only, shown once):
|
||||
<br />
|
||||
abc123xyz...
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port {backendPort}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background max-h-screen">
|
||||
<AppHeader
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
isLoading={isLoadingEntities}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content - Split Panel or Gallery */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{showGallery ? (
|
||||
// Show gallery full screen (w-full ensures it takes entire width)
|
||||
<div className="flex-1 w-full">
|
||||
<GalleryView
|
||||
variant="route"
|
||||
onClose={() => setShowGallery(false)}
|
||||
hasExistingEntities={
|
||||
agents.length > 0 || workflows.length > 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : agents.length === 0 && workflows.length === 0 ? (
|
||||
// Empty state - show gallery inline (full width, no debug panel)
|
||||
<GalleryView variant="inline" />
|
||||
) : (
|
||||
<>
|
||||
{/* Left Panel - Main View */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedAgent ? (
|
||||
selectedAgent.type === "agent" ? (
|
||||
<AgentView
|
||||
selectedAgent={selectedAgent as AgentInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowView
|
||||
selectedWorkflow={selectedAgent as WorkflowInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select an agent or workflow to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uiMode === "developer" && showDebugPanel ? (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
|
||||
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
|
||||
}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
|
||||
isResizing
|
||||
? "bg-primary shadow-lg shadow-primary/25"
|
||||
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
|
||||
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
|
||||
>
|
||||
{debugPanelMinimized ? (
|
||||
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
|
||||
<div
|
||||
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setDebugPanelMinimized(false)}
|
||||
title="Expand debug panel"
|
||||
>
|
||||
{/* Expand button at top (visual affordance) */}
|
||||
<div className="h-8 w-8 flex items-center justify-center">
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Text and count centered in middle */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
|
||||
<div
|
||||
className="text-xs text-muted-foreground select-none"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
transform: 'rotate(180deg)'
|
||||
}}
|
||||
>
|
||||
Debug Panel
|
||||
</div>
|
||||
{debugEvents.length > 0 && (
|
||||
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
|
||||
style={{ fontSize: '10px' }}>
|
||||
{debugEvents.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onMinimize={() => setDebugPanelMinimized(true)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
{azureDeploymentEnabled && selectedAgent?.deployment_supported
|
||||
? "Deploy to Azure"
|
||||
: "Deployment Guide"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : uiMode === "developer" ? (
|
||||
/* Button to reopen when closed */
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDebugPanel(true)}
|
||||
className="h-full w-10 rounded-none border-l"
|
||||
title="Show debug panel"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
|
||||
|
||||
{/* Deployment Modal */}
|
||||
<DeploymentModal
|
||||
open={showDeployModal}
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
agentName={selectedAgent?.name}
|
||||
entity={selectedAgent}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showEntityNotFoundToast && (
|
||||
<Toast
|
||||
message="Entity not found. Showing first available entity instead."
|
||||
type="info"
|
||||
onClose={() => setShowEntityNotFoundToast(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Container for reload and other notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Bot,
|
||||
Package,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { AgentInfo } from "@/types";
|
||||
|
||||
interface AgentDetailsModalProps {
|
||||
agent: AgentInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDetailsModal({
|
||||
agent,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
agent.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : agent.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
agent.source === "directory"
|
||||
? "Local"
|
||||
: agent.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Agent Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{agent.name || agent.id}
|
||||
</h2>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-muted-foreground">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Model & Client */}
|
||||
{(agent.model_id || agent.chat_client_type) && (
|
||||
<DetailCard
|
||||
title="Model & Client"
|
||||
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agent.model_id && (
|
||||
<div className="font-mono text-foreground">{agent.model_id}</div>
|
||||
)}
|
||||
{agent.chat_client_type && (
|
||||
<div className="text-xs">({agent.chat_client_type})</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{agent.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{agent.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
agent.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
agent.has_env
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{agent.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Full Width Sections */}
|
||||
{agent.instructions && (
|
||||
<DetailCard
|
||||
title="Instructions"
|
||||
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{agent.instructions}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Tools and MiddlewareTypes Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tools */}
|
||||
{agent.tools && agent.tools.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Tools (${agent.tools.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.tools.map((tool, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {tool}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Middlewares */}
|
||||
{agent.middleware && agent.middleware.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Middlewares (${agent.middleware.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.middleware.map((mw, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {mw}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Context Provider */}
|
||||
{agent.context_provider && (
|
||||
<DetailCard
|
||||
title="Context Provider"
|
||||
icon={<Database className="h-4 w-4 text-muted-foreground" />}
|
||||
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
|
||||
>
|
||||
<div className="font-mono text-xs text-foreground">
|
||||
{agent.context_provider}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,949 @@
|
||||
/**
|
||||
* ContextInspector - Token usage visualization and context analysis
|
||||
*
|
||||
* Features:
|
||||
* - Stacked bar chart showing input/output tokens per turn
|
||||
* - Composition view showing what fills the context (system, user, assistant, tools)
|
||||
* - Per-turn vs cumulative modes
|
||||
* - Summary statistics (total, average, peak)
|
||||
* - Pure CSS visualization (no external charting library)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useDevUIStore } from "@/stores/devuiStore";
|
||||
import {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import {
|
||||
TraceAttributes,
|
||||
type TypedTraceAttributes,
|
||||
type TraceMessage,
|
||||
parseTraceMessages,
|
||||
isTextPart,
|
||||
isToolCallPart,
|
||||
isToolResultPart,
|
||||
} from "@/types/openai";
|
||||
|
||||
// Trace data interface matching debug-panel types
|
||||
interface TraceEventData {
|
||||
operation_name?: string;
|
||||
duration_ms?: number;
|
||||
status?: string;
|
||||
attributes?: TypedTraceAttributes;
|
||||
span_id?: string;
|
||||
trace_id?: string;
|
||||
parent_span_id?: string | null;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
entity_id?: string;
|
||||
response_id?: string | null;
|
||||
}
|
||||
|
||||
// Context composition breakdown
|
||||
interface ContextComposition {
|
||||
system: number; // character count
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number; // function definitions + arguments
|
||||
toolResults: number; // function outputs
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Turn data extracted from traces
|
||||
interface TurnData {
|
||||
response_id: string;
|
||||
timestamp: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
model?: string;
|
||||
entity_id?: string;
|
||||
duration_ms: number;
|
||||
composition: ContextComposition;
|
||||
}
|
||||
|
||||
// Props for the component
|
||||
interface ContextInspectorProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
}
|
||||
|
||||
// Parse message content to extract composition using typed TraceMessage format
|
||||
function parseComposition(messagesJson: string | unknown): ContextComposition {
|
||||
const composition: ContextComposition = {
|
||||
system: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Use the typed parser for string input
|
||||
let messages: TraceMessage[];
|
||||
|
||||
if (typeof messagesJson === "string") {
|
||||
messages = parseTraceMessages(messagesJson);
|
||||
} else if (Array.isArray(messagesJson)) {
|
||||
messages = messagesJson as TraceMessage[];
|
||||
} else {
|
||||
return composition;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
|
||||
const role = message.role;
|
||||
const parts = message.parts;
|
||||
|
||||
// Calculate character count for this message
|
||||
let charCount = 0;
|
||||
|
||||
// Handle parts array (Agent Framework format)
|
||||
// Using type guards for type-safe access to part properties
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
|
||||
if (isTextPart(part)) {
|
||||
// Text content can be in either 'content' or 'text' field
|
||||
const text = part.content || part.text || "";
|
||||
charCount += text.length;
|
||||
} else if (isToolCallPart(part)) {
|
||||
// Tool call includes name and arguments
|
||||
const name = part.name || "";
|
||||
const args = part.arguments || "";
|
||||
composition.toolCalls += name.length + args.length;
|
||||
} else if (isToolResultPart(part)) {
|
||||
// Tool result - check both 'result' and 'response' fields
|
||||
const result = part.result || part.response || "";
|
||||
composition.toolResults += result.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Categorize by role
|
||||
if (role === "system") {
|
||||
composition.system += charCount;
|
||||
} else if (role === "user") {
|
||||
composition.user += charCount;
|
||||
} else if (role === "assistant") {
|
||||
composition.assistant += charCount;
|
||||
} else if (role === "tool") {
|
||||
composition.toolResults += charCount;
|
||||
}
|
||||
}
|
||||
|
||||
composition.total =
|
||||
composition.system +
|
||||
composition.user +
|
||||
composition.assistant +
|
||||
composition.toolCalls +
|
||||
composition.toolResults;
|
||||
|
||||
} catch {
|
||||
// Parsing failed, return empty composition
|
||||
}
|
||||
|
||||
return composition;
|
||||
}
|
||||
|
||||
// Extract turn data from trace events
|
||||
function extractTurnData(events: ExtendedResponseStreamEvent[]): TurnData[] {
|
||||
const traceEvents = events.filter(e => e.type === "response.trace.completed");
|
||||
|
||||
// Group by response_id
|
||||
const byResponseId = new Map<string, TraceEventData[]>();
|
||||
|
||||
for (const event of traceEvents) {
|
||||
if (!("data" in event)) continue;
|
||||
const data = event.data as TraceEventData;
|
||||
const responseId = data.response_id || "unknown";
|
||||
|
||||
if (!byResponseId.has(responseId)) {
|
||||
byResponseId.set(responseId, []);
|
||||
}
|
||||
byResponseId.get(responseId)!.push(data);
|
||||
}
|
||||
|
||||
const turns: TurnData[] = [];
|
||||
|
||||
for (const [responseId, traces] of byResponseId) {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let model: string | undefined;
|
||||
let timestamp = Date.now() / 1000;
|
||||
let entity_id: string | undefined;
|
||||
let totalDuration = 0;
|
||||
let composition: ContextComposition = {
|
||||
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
|
||||
};
|
||||
|
||||
for (const trace of traces) {
|
||||
const attrs = trace.attributes || {};
|
||||
|
||||
// Get token counts using typed attribute keys
|
||||
const traceInput = attrs[TraceAttributes.INPUT_TOKENS];
|
||||
const traceOutput = attrs[TraceAttributes.OUTPUT_TOKENS];
|
||||
|
||||
if (traceInput !== undefined) {
|
||||
inputTokens += Number(traceInput);
|
||||
}
|
||||
if (traceOutput !== undefined) {
|
||||
outputTokens += Number(traceOutput);
|
||||
}
|
||||
|
||||
// Get model using typed attribute key
|
||||
if (attrs[TraceAttributes.MODEL]) {
|
||||
model = String(attrs[TraceAttributes.MODEL]);
|
||||
}
|
||||
|
||||
// Get timestamp
|
||||
if (trace.start_time && trace.start_time < timestamp) {
|
||||
timestamp = trace.start_time;
|
||||
}
|
||||
|
||||
// Get entity_id
|
||||
if (trace.entity_id) {
|
||||
entity_id = trace.entity_id;
|
||||
}
|
||||
|
||||
// Sum durations
|
||||
if (trace.duration_ms) {
|
||||
totalDuration += Number(trace.duration_ms);
|
||||
}
|
||||
|
||||
// Parse composition from input messages using typed attribute key
|
||||
const inputMessages = attrs[TraceAttributes.INPUT_MESSAGES];
|
||||
if (inputMessages && composition.total === 0) {
|
||||
composition = parseComposition(inputMessages);
|
||||
}
|
||||
|
||||
// Also check for system instructions using typed attribute key
|
||||
const systemInstructions = attrs[TraceAttributes.SYSTEM_INSTRUCTIONS];
|
||||
if (systemInstructions && typeof systemInstructions === "string" && composition.system === 0) {
|
||||
composition.system = systemInstructions.length;
|
||||
composition.total += systemInstructions.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include turns that have token data
|
||||
if (inputTokens > 0 || outputTokens > 0) {
|
||||
turns.push({
|
||||
response_id: responseId,
|
||||
timestamp,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
model,
|
||||
entity_id,
|
||||
duration_ms: totalDuration,
|
||||
composition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
turns.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
function calculateStats(turns: TurnData[]) {
|
||||
if (turns.length === 0) {
|
||||
return {
|
||||
totalInput: 0,
|
||||
totalOutput: 0,
|
||||
totalTokens: 0,
|
||||
avgInput: 0,
|
||||
avgOutput: 0,
|
||||
avgTotal: 0,
|
||||
peakInput: 0,
|
||||
peakOutput: 0,
|
||||
peakTotal: 0,
|
||||
turnCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalInput = turns.reduce((sum, t) => sum + t.input_tokens, 0);
|
||||
const totalOutput = turns.reduce((sum, t) => sum + t.output_tokens, 0);
|
||||
const totalTokens = totalInput + totalOutput;
|
||||
|
||||
const peakInput = Math.max(...turns.map(t => t.input_tokens));
|
||||
const peakOutput = Math.max(...turns.map(t => t.output_tokens));
|
||||
const peakTotal = Math.max(...turns.map(t => t.total_tokens));
|
||||
|
||||
return {
|
||||
totalInput,
|
||||
totalOutput,
|
||||
totalTokens,
|
||||
avgInput: Math.round(totalInput / turns.length),
|
||||
avgOutput: Math.round(totalOutput / turns.length),
|
||||
avgTotal: Math.round(totalTokens / turns.length),
|
||||
peakInput,
|
||||
peakOutput,
|
||||
peakTotal,
|
||||
turnCount: turns.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate composition across all turns
|
||||
function aggregateComposition(turns: TurnData[]): ContextComposition {
|
||||
return turns.reduce(
|
||||
(acc, turn) => ({
|
||||
system: acc.system + turn.composition.system,
|
||||
user: acc.user + turn.composition.user,
|
||||
assistant: acc.assistant + turn.composition.assistant,
|
||||
toolCalls: acc.toolCalls + turn.composition.toolCalls,
|
||||
toolResults: acc.toolResults + turn.composition.toolResults,
|
||||
total: acc.total + turn.composition.total,
|
||||
}),
|
||||
{ system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
// Format large numbers with K suffix
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Color constants - single source of truth for all visualizations
|
||||
const SEGMENT_COLORS = {
|
||||
// Token segments
|
||||
input: "bg-blue-500 dark:bg-blue-600",
|
||||
output: "bg-emerald-500 dark:bg-emerald-600",
|
||||
// Composition segments
|
||||
system: "bg-purple-500 dark:bg-purple-600",
|
||||
user: "bg-blue-500 dark:bg-blue-600",
|
||||
assistant: "bg-emerald-500 dark:bg-emerald-600",
|
||||
toolCalls: "bg-amber-500 dark:bg-amber-600",
|
||||
toolResults: "bg-orange-500 dark:bg-orange-600",
|
||||
} as const;
|
||||
|
||||
// Segment definition for the unified bar component
|
||||
interface BarSegment {
|
||||
key: string;
|
||||
value: number;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Unified segmented bar component with tooltips
|
||||
// Replaces both TokenBar and CompositionBar for consistency and maintainability
|
||||
function SegmentedBar({
|
||||
segments,
|
||||
maxValue,
|
||||
height = 20,
|
||||
renderLabel,
|
||||
}: {
|
||||
segments: BarSegment[];
|
||||
maxValue: number;
|
||||
height?: number;
|
||||
renderLabel?: (total: number, segments: BarSegment[]) => React.ReactNode;
|
||||
}) {
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
className="rounded bg-muted/30 flex-1"
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When maxValue is 0, use full width (100%) - focus on ratios within the bar
|
||||
// When maxValue > 0, scale relative to max - focus on size comparison
|
||||
const widthPercent = maxValue > 0 ? (total / maxValue) * 100 : 100;
|
||||
|
||||
// Pre-compute segment metadata for tooltips
|
||||
const segmentsWithMeta = segments
|
||||
.filter(s => s.value > 0)
|
||||
.map(seg => ({
|
||||
...seg,
|
||||
percent: Math.round((seg.value / total) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
className="relative rounded overflow-hidden bg-muted/30 flex-1"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div
|
||||
className="h-full flex transition-all duration-300"
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
{segmentsWithMeta.map((seg) => (
|
||||
<Tooltip key={seg.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-full ${seg.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`}
|
||||
style={{ width: `${(seg.value / total) * 100}%` }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-sm ${seg.color} flex-shrink-0`} />
|
||||
<span className="font-medium">{seg.label}</span>
|
||||
<span className="opacity-80">{formatTokenCount(seg.value)} ({seg.percent}%)</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{renderLabel?.(total, segments)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to create token segments (input/output)
|
||||
function createTokenSegments(input: number, output: number): BarSegment[] {
|
||||
return [
|
||||
{ key: "input", value: input, color: SEGMENT_COLORS.input, label: "Input" },
|
||||
{ key: "output", value: output, color: SEGMENT_COLORS.output, label: "Output" },
|
||||
];
|
||||
}
|
||||
|
||||
// Helper to create composition segments
|
||||
function createCompositionSegments(composition: ContextComposition): BarSegment[] {
|
||||
return [
|
||||
{ key: "system", value: composition.system, color: SEGMENT_COLORS.system, label: "System" },
|
||||
{ key: "user", value: composition.user, color: SEGMENT_COLORS.user, label: "User" },
|
||||
{ key: "assistant", value: composition.assistant, color: SEGMENT_COLORS.assistant, label: "Assistant" },
|
||||
{ key: "toolCalls", value: composition.toolCalls, color: SEGMENT_COLORS.toolCalls, label: "Tool Calls" },
|
||||
{ key: "toolResults", value: composition.toolResults, color: SEGMENT_COLORS.toolResults, label: "Tool Results" },
|
||||
];
|
||||
}
|
||||
|
||||
// Composition breakdown list
|
||||
function CompositionBreakdown({
|
||||
composition,
|
||||
className = "",
|
||||
}: {
|
||||
composition: ContextComposition;
|
||||
className?: string;
|
||||
}) {
|
||||
const { system, user, assistant, toolCalls, toolResults, total } = composition;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={`text-xs text-muted-foreground ${className}`}>
|
||||
No composition data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: "System", value: system, color: SEGMENT_COLORS.system },
|
||||
{ label: "User", value: user, color: SEGMENT_COLORS.user },
|
||||
{ label: "Assistant", value: assistant, color: SEGMENT_COLORS.assistant },
|
||||
{ label: "Tool Calls", value: toolCalls, color: SEGMENT_COLORS.toolCalls },
|
||||
{ label: "Tool Results", value: toolResults, color: SEGMENT_COLORS.toolResults },
|
||||
].filter(item => item.value > 0);
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{items.map((item) => {
|
||||
const percent = Math.round((item.value / total) * 100);
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-sm ${item.color}`} />
|
||||
<span className="text-muted-foreground w-20">{item.label}</span>
|
||||
<div className="flex-1 h-3 bg-muted/30 rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} transition-all duration-300`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono w-10 text-right text-muted-foreground">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Turn row component
|
||||
function TurnRow({
|
||||
turn,
|
||||
index,
|
||||
maxValue,
|
||||
maxCompositionValue,
|
||||
cumulativeInput,
|
||||
cumulativeOutput,
|
||||
cumulativeComposition,
|
||||
showCumulative,
|
||||
viewMode,
|
||||
}: {
|
||||
turn: TurnData;
|
||||
index: number;
|
||||
maxValue: number;
|
||||
maxCompositionValue: number;
|
||||
cumulativeInput: number;
|
||||
cumulativeOutput: number;
|
||||
cumulativeComposition: ContextComposition;
|
||||
showCumulative: boolean;
|
||||
viewMode: "tokens" | "composition";
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const displayInput = showCumulative ? cumulativeInput : turn.input_tokens;
|
||||
const displayOutput = showCumulative ? cumulativeOutput : turn.output_tokens;
|
||||
const displayComposition = showCumulative ? cumulativeComposition : turn.composition;
|
||||
|
||||
const timestamp = new Date(turn.timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-b border-muted/50 last:border-0">
|
||||
<div
|
||||
className="flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{/* Turn number */}
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{viewMode === "tokens" ? (
|
||||
<SegmentedBar
|
||||
segments={createTokenSegments(displayInput, displayOutput)}
|
||||
maxValue={maxValue}
|
||||
height={20}
|
||||
renderLabel={(_, segs) => (
|
||||
<div className="flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end">
|
||||
<span className="text-blue-600 dark:text-blue-400">↑{formatTokenCount(segs[0]?.value || 0)}</span>
|
||||
<span>/</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">↓{formatTokenCount(segs[1]?.value || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SegmentedBar
|
||||
segments={createCompositionSegments(displayComposition)}
|
||||
maxValue={maxCompositionValue}
|
||||
height={20}
|
||||
renderLabel={(total) => (
|
||||
<div className="text-xs font-mono text-muted-foreground min-w-[50px] text-right">
|
||||
{formatTokenCount(Math.round(total / 4))}~
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand icon */}
|
||||
<div className="text-muted-foreground flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="pb-3">
|
||||
{/* Connector line */}
|
||||
<div className="flex items-start gap-3 px-2">
|
||||
<div className="w-6 flex justify-center flex-shrink-0">
|
||||
<div className="w-px h-full bg-muted" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* L-connector and composition */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-muted-foreground text-xs mt-1">└─</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<div>Time: <span className="font-mono text-foreground">{timestamp}</span></div>
|
||||
<div>Duration: <span className="font-mono text-foreground">{turn.duration_ms.toFixed(0)}ms</span></div>
|
||||
{turn.model && (
|
||||
<div>Model: <span className="font-mono text-foreground">{turn.model}</span></div>
|
||||
)}
|
||||
{turn.entity_id && (
|
||||
<div>Entity: <span className="font-mono text-foreground">{turn.entity_id}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token counts - shown in tokens mode */}
|
||||
{viewMode === "tokens" && (
|
||||
<div className="flex gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-blue-600 dark:text-blue-400">Input:</span>{" "}
|
||||
<span className="font-mono">{turn.input_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">Output:</span>{" "}
|
||||
<span className="font-mono">{turn.output_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total:</span>{" "}
|
||||
<span className="font-mono">{turn.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composition breakdown - shown in composition mode */}
|
||||
{viewMode === "composition" && turn.composition.total > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
Context Composition (estimated from ~{formatTokenCount(Math.round(turn.composition.total / 4))} tokens)
|
||||
</div>
|
||||
<CompositionBreakdown composition={turn.composition} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary stats card
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: typeof BarChart3;
|
||||
color?: "default" | "blue" | "green";
|
||||
}) {
|
||||
const colorClass = {
|
||||
default: "text-muted-foreground",
|
||||
blue: "text-blue-600 dark:text-blue-400",
|
||||
green: "text-emerald-600 dark:text-emerald-400",
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded">
|
||||
<Icon className={`h-4 w-4 ${colorClass}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-muted-foreground truncate">{label}</div>
|
||||
<div className="font-mono text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export function ContextInspector({ events }: ContextInspectorProps) {
|
||||
// Use persisted store state instead of local useState
|
||||
const viewMode = useDevUIStore((state) => state.contextInspectorViewMode);
|
||||
const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode);
|
||||
const showCumulative = useDevUIStore((state) => state.contextInspectorCumulative);
|
||||
const setShowCumulative = useDevUIStore((state) => state.setContextInspectorCumulative);
|
||||
|
||||
// Extract turn data from traces
|
||||
const turns = useMemo(() => extractTurnData(events), [events]);
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => calculateStats(turns), [turns]);
|
||||
|
||||
// Aggregate composition
|
||||
const totalComposition = useMemo(() => aggregateComposition(turns), [turns]);
|
||||
|
||||
// Calculate max value for bar scaling (tokens)
|
||||
// In non-cumulative mode, use 0 to signal full-width bars (focus on ratios)
|
||||
// In cumulative mode, scale relative to total (focus on growth)
|
||||
const maxValue = useMemo(() => {
|
||||
if (turns.length === 0) return 0;
|
||||
|
||||
if (showCumulative) {
|
||||
return stats.totalTokens;
|
||||
} else {
|
||||
// Return 0 to signal "use full width" - each bar shows its own ratio
|
||||
return 0;
|
||||
}
|
||||
}, [turns, showCumulative, stats.totalTokens]);
|
||||
|
||||
// Calculate max value for composition bar scaling
|
||||
// Same logic: full-width in non-cumulative, scaled in cumulative
|
||||
const maxCompositionValue = useMemo(() => {
|
||||
if (turns.length === 0) return 0;
|
||||
|
||||
if (showCumulative) {
|
||||
return totalComposition.total;
|
||||
} else {
|
||||
// Return 0 to signal "use full width"
|
||||
return 0;
|
||||
}
|
||||
}, [turns, showCumulative, totalComposition.total]);
|
||||
|
||||
// Calculate cumulative values for tokens and composition
|
||||
const cumulativeData = useMemo(() => {
|
||||
let cumInput = 0;
|
||||
let cumOutput = 0;
|
||||
let cumComposition: ContextComposition = {
|
||||
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
|
||||
};
|
||||
|
||||
return turns.map(t => {
|
||||
cumInput += t.input_tokens;
|
||||
cumOutput += t.output_tokens;
|
||||
cumComposition = {
|
||||
system: cumComposition.system + t.composition.system,
|
||||
user: cumComposition.user + t.composition.user,
|
||||
assistant: cumComposition.assistant + t.composition.assistant,
|
||||
toolCalls: cumComposition.toolCalls + t.composition.toolCalls,
|
||||
toolResults: cumComposition.toolResults + t.composition.toolResults,
|
||||
total: cumComposition.total + t.composition.total,
|
||||
};
|
||||
return {
|
||||
input: cumInput,
|
||||
output: cumOutput,
|
||||
composition: { ...cumComposition }
|
||||
};
|
||||
});
|
||||
}, [turns]);
|
||||
|
||||
// No data state
|
||||
if (turns.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center p-6 pt-9">
|
||||
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<div className="text-sm font-medium mb-1">No Data</div>
|
||||
<div className="text-xs text-muted-foreground max-w-[200px]">
|
||||
Run{" "}
|
||||
<span className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --instrumentation
|
||||
</span>{" "}
|
||||
and start a conversation.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b flex-shrink-0 space-y-2">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">Context Inspector</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{turns.length} turn{turns.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Cumulative checkbox */}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showCumulative}
|
||||
onCheckedChange={(checked) => setShowCumulative(checked === true)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>Cumulative</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* View mode segmented control */}
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("tokens")}
|
||||
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
viewMode === "tokens"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("composition")}
|
||||
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
viewMode === "composition"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Composition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View mode description */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{viewMode === "tokens"
|
||||
? "Token usage per turn"
|
||||
: "Context breakdown by message type (chars)"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs px-1 flex-wrap">
|
||||
{viewMode === "tokens" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.input}`} />
|
||||
<span className="text-muted-foreground">Input (↑)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.output}`} />
|
||||
<span className="text-muted-foreground">Output (↓)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.system}`} />
|
||||
<span className="text-muted-foreground">System</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.user}`} />
|
||||
<span className="text-muted-foreground">User</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.assistant}`} />
|
||||
<span className="text-muted-foreground">Assistant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolCalls}`} />
|
||||
<span className="text-muted-foreground">Tools</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolResults}`} />
|
||||
<span className="text-muted-foreground">Results</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Click for details</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turn bars */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{turns.map((turn, index) => (
|
||||
<TurnRow
|
||||
key={turn.response_id}
|
||||
turn={turn}
|
||||
index={index}
|
||||
maxValue={maxValue}
|
||||
maxCompositionValue={maxCompositionValue}
|
||||
cumulativeInput={cumulativeData[index]?.input || 0}
|
||||
cumulativeOutput={cumulativeData[index]?.output || 0}
|
||||
cumulativeComposition={cumulativeData[index]?.composition || turn.composition}
|
||||
showCumulative={showCumulative}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Session summary */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="p-3 bg-muted/30 border-b">
|
||||
<span className="text-xs font-medium">Session Summary</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Token summary cards */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatTokenCount(stats.totalTokens)}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Input"
|
||||
value={formatTokenCount(stats.totalInput)}
|
||||
icon={BarChart3}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Output"
|
||||
value={formatTokenCount(stats.totalOutput)}
|
||||
icon={BarChart3}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-turn statistics (only for multi-turn sessions) */}
|
||||
{turns.length > 1 && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg per turn:</span>
|
||||
<span className="font-mono">{formatTokenCount(stats.avgTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Peak turn:</span>
|
||||
<span className="font-mono">{formatTokenCount(stats.peakTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg input:</span>
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{formatTokenCount(stats.avgInput)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg output:</span>
|
||||
<span className="font-mono text-emerald-600 dark:text-emerald-400">{formatTokenCount(stats.avgOutput)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total composition */}
|
||||
{totalComposition.total > 0 && (
|
||||
<div className="pt-3 border-t border-muted/50">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-muted-foreground text-xs mt-0.5">└─</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
Total Composition (all turns)
|
||||
</div>
|
||||
<CompositionBreakdown composition={totalComposition} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Agent Feature - Exports
|
||||
*/
|
||||
|
||||
export { AgentView } from "./agent-view";
|
||||
export { AgentDetailsModal } from "./agent-details-modal";
|
||||
export * from "./message-renderers";
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
|
||||
* This is the CORRECT implementation that works with OpenAI types only
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Music,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: MessageContent;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
if (content.type !== "text" && content.type !== "input_text" && content.type !== "output_text") return null;
|
||||
|
||||
const text = content.text;
|
||||
|
||||
return (
|
||||
<div className={`break-words ${className || ""}`}>
|
||||
<MarkdownRenderer content={text} />
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer (handles both input and output images)
|
||||
function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "input_image" && content.type !== "output_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Image could not be loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Uploaded image"
|
||||
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
|
||||
isExpanded ? "max-h-none" : "max-h-64"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Click to collapse
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to convert base64 (or data URI) to blob URL for better browser compatibility
|
||||
function useBase64ToBlobUrl(data: string | undefined, mimeType: string): string | null {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
setBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle both data URI format and raw base64
|
||||
let base64Data: string;
|
||||
if (data.startsWith('data:')) {
|
||||
// Extract base64 from data URI (e.g., "data:application/pdf;base64,...")
|
||||
const parts = data.split(',');
|
||||
if (parts.length !== 2) {
|
||||
setBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
base64Data = parts[1];
|
||||
} else {
|
||||
// Raw base64 data
|
||||
base64Data = data;
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
|
||||
// Cleanup on unmount or when data changes
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to convert base64 to blob URL:', error);
|
||||
setBlobUrl(null);
|
||||
}
|
||||
}, [data, mimeType]);
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
// File content renderer (handles both input and output files)
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// Determine file properties (must be before hooks for conditional logic)
|
||||
const isFileContent = content.type === "input_file" || content.type === "output_file";
|
||||
const fileUrl = isFileContent ? (content.file_url || content.file_data) : undefined;
|
||||
const filename = isFileContent ? (content.filename || "file") : undefined;
|
||||
|
||||
// Determine file type from filename or data URI
|
||||
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
|
||||
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
|
||||
|
||||
// Convert base64 to blob URL for PDFs (better browser compatibility)
|
||||
// Use file_data (raw base64) if available, otherwise try file_url
|
||||
// Hook must be called unconditionally - pass undefined if not a PDF
|
||||
const pdfData = (isFileContent && isPdf) ? (content.file_data || content.file_url) : undefined;
|
||||
const pdfBlobUrl = useBase64ToBlobUrl(pdfData, 'application/pdf');
|
||||
|
||||
// Early return after all hooks
|
||||
if (!isFileContent) return null;
|
||||
|
||||
// Use blob URL if available, otherwise fall back to original URL
|
||||
const effectivePdfUrl = pdfBlobUrl || fileUrl;
|
||||
|
||||
// Helper to open PDF in new tab
|
||||
const openPdfInNewTab = () => {
|
||||
if (effectivePdfUrl) {
|
||||
window.open(effectivePdfUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// For PDFs - show a clean card with actions (inline preview is unreliable across browsers)
|
||||
if (isPdf && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
{/* Header with filename and controls */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<FileText className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm font-medium truncate flex-1">{filename}</span>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PDF Card with actions */}
|
||||
{isExpanded && (
|
||||
<div className="border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4">
|
||||
<FileText className="h-16 w-16 text-red-400" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium mb-1">{filename}</p>
|
||||
<p className="text-xs text-muted-foreground">PDF Document</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={openPdfInNewTab}
|
||||
className="text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
Open in new tab
|
||||
</button>
|
||||
<a
|
||||
href={effectivePdfUrl || fileUrl}
|
||||
download={filename}
|
||||
className="text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For audio files
|
||||
if (isAudio && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{filename}</span>
|
||||
</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} />
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic file display
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{filename}</span>
|
||||
</div>
|
||||
{fileUrl && (
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={filename}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data content renderer (for generic structured data outputs)
|
||||
function DataContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "output_data") return null;
|
||||
|
||||
const data = content.data;
|
||||
const mimeType = content.mime_type;
|
||||
const description = content.description;
|
||||
|
||||
// Try to parse as JSON for pretty printing
|
||||
let displayData = data;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
displayData = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{description || "Data Output"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
|
||||
{displayData}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function approval request renderer - compact version
|
||||
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
|
||||
// Hooks must be called unconditionally
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Early return after hooks
|
||||
if (content.type !== "function_approval_request") return null;
|
||||
|
||||
const { status, function_call } = content;
|
||||
|
||||
// Status styling - compact
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
label: "Awaiting approval",
|
||||
iconClass: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
approved: {
|
||||
icon: Check,
|
||||
label: "Approved",
|
||||
iconClass: "text-green-600 dark:text-green-400",
|
||||
},
|
||||
rejected: {
|
||||
icon: X,
|
||||
label: "Rejected",
|
||||
iconClass: "text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof function_call.arguments === "string"
|
||||
? JSON.parse(function_call.arguments)
|
||||
: function_call.arguments;
|
||||
} catch {
|
||||
parsedArgs = function_call.arguments;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit"
|
||||
>
|
||||
<StatusIcon className={`h-3 w-3 ${config.iconClass}`} />
|
||||
<span className="text-muted-foreground font-mono">{function_call.name}</span>
|
||||
<span className={`text-xs ${config.iconClass}`}>{config.label}</span>
|
||||
{isExpanded ? (
|
||||
<span className="text-xs text-muted-foreground">▼</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">▶</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3">
|
||||
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(parsedArgs, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content renderer that delegates to specific renderers
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
case "input_text":
|
||||
case "output_text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
case "output_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
case "input_file":
|
||||
case "output_file":
|
||||
return <FileContentRenderer content={content} className={className} />;
|
||||
case "output_data":
|
||||
return <DataContentRenderer content={content} className={className} />;
|
||||
case "function_approval_request":
|
||||
return <FunctionApprovalRequestRenderer content={content} className={className} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function call renderer (for displaying function calls in chat)
|
||||
interface FunctionCallRendererProps {
|
||||
name: string;
|
||||
arguments: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
|
||||
} catch {
|
||||
parsedArgs = args;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Function Call: {name}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function result renderer
|
||||
interface FunctionResultRendererProps {
|
||||
output: string;
|
||||
call_id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedOutput;
|
||||
try {
|
||||
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
|
||||
} catch {
|
||||
parsedOutput = output;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
Function Result
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedOutput, null, 2)}
|
||||
</pre>
|
||||
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
|
||||
* This replaces the legacy AgentFramework-based renderer
|
||||
*/
|
||||
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import {
|
||||
OpenAIContentRenderer,
|
||||
FunctionCallRenderer,
|
||||
FunctionResultRenderer,
|
||||
} from "./OpenAIContentRenderer";
|
||||
|
||||
interface OpenAIMessageRendererProps {
|
||||
item: ConversationItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OpenAIMessageRenderer({
|
||||
item,
|
||||
className,
|
||||
}: OpenAIMessageRendererProps) {
|
||||
// Handle message items (user/assistant with content)
|
||||
if (item.type === "message") {
|
||||
// Determine if message is actively streaming
|
||||
const isStreaming = item.status === "in_progress";
|
||||
const hasContent = item.content.length > 0;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{item.content.map((content, index) => (
|
||||
<OpenAIContentRenderer
|
||||
key={index}
|
||||
content={content}
|
||||
className={index > 0 ? "mt-2" : ""}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show typing indicator when streaming with no content yet */}
|
||||
{isStreaming && !hasContent && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex space-x-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function call items
|
||||
if (item.type === "function_call") {
|
||||
return (
|
||||
<FunctionCallRenderer
|
||||
name={item.name}
|
||||
arguments={item.arguments}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function result items
|
||||
if (item.type === "function_call_output") {
|
||||
return (
|
||||
<FunctionResultRenderer
|
||||
output={item.output}
|
||||
call_id={item.call_id}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown item type
|
||||
return null;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Message Renderer - Exports
|
||||
* Uses OpenAI Responses API types exclusively
|
||||
*/
|
||||
|
||||
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
|
||||
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* GalleryView - Consolidated gallery component with card and grid logic
|
||||
* Supports inline (empty state) and modal variants
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
User,
|
||||
TriangleAlert,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SAMPLE_ENTITIES,
|
||||
type SampleEntity,
|
||||
getDifficultyColor,
|
||||
} from "@/data/gallery";
|
||||
import { SetupInstructionsModal } from "./setup-instructions-modal";
|
||||
|
||||
interface GalleryViewProps {
|
||||
onClose?: () => void;
|
||||
variant?: "inline" | "route" | "modal";
|
||||
hasExistingEntities?: boolean;
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Card Component
|
||||
function SampleEntityCard({
|
||||
sample,
|
||||
}: {
|
||||
sample: SampleEntity;
|
||||
}) {
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sample.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{sample.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{sample.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Required - Collapsible */}
|
||||
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
|
||||
<details className="group min-w-0 max-w-full overflow-hidden">
|
||||
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
|
||||
Requires {sample.requiredEnvVars.length} env var
|
||||
{sample.requiredEnvVars.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
|
||||
{sample.requiredEnvVars.map((envVar) => (
|
||||
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
|
||||
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
|
||||
{envVar.name}
|
||||
</div>
|
||||
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
|
||||
{envVar.description}
|
||||
</div>
|
||||
{envVar.example && (
|
||||
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
|
||||
{envVar.example}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Key Features:
|
||||
</div>
|
||||
<ul className="text-xs space-y-1">
|
||||
{sample.features.slice(0, 3).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="w-full flex gap-2">
|
||||
<Button asChild className="flex-1" size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => setShowInstructions(true)}
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Setup Guide
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<SetupInstructionsModal
|
||||
sample={sample}
|
||||
open={showInstructions}
|
||||
onOpenChange={setShowInstructions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Grid Component
|
||||
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{samples.map((sample) => (
|
||||
<div key={sample.id} className="min-w-0">
|
||||
<SampleEntityCard sample={sample} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main: Gallery View Component
|
||||
export function GalleryView({
|
||||
onClose,
|
||||
variant = "inline",
|
||||
hasExistingEntities = false,
|
||||
}: GalleryViewProps) {
|
||||
// Inline variant - for empty state in main app
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Info Banner */}
|
||||
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">
|
||||
No agents or workflows configured yet!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You can configure agents or workflows by running{" "}
|
||||
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
|
||||
devui
|
||||
</code>{" "}
|
||||
in a directory containing them.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse the sample agents and workflows below. Download them,
|
||||
review the code, and run them locally to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Route variant - for /gallery page
|
||||
if (variant === "route") {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
{hasExistingEntities && (
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" onClick={onClose} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Browse sample agents and workflows to learn the Agent
|
||||
Framework. Download these curated examples and run them locally.
|
||||
Examples range from beginner to advanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal variant - for dropdown trigger (simplified, just the grid)
|
||||
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Gallery component exports
|
||||
*/
|
||||
|
||||
export { GalleryView } from './gallery-view';
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
|
||||
interface SetupInstructionsModalProps {
|
||||
sample: SampleEntity;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
{copyable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 h-6 w-6 p-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStep({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
code,
|
||||
action,
|
||||
copyable = false,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
action?: React.ReactNode;
|
||||
copyable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
|
||||
{number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupInstructionsModal({
|
||||
sample,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SetupInstructionsModalProps) {
|
||||
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
|
||||
const stepOffset = hasEnvVars ? 0 : -1;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>Setup: {sample.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Follow these steps to run this sample {sample.type} locally
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Step 1: Download */}
|
||||
<SetupStep
|
||||
number={1}
|
||||
title="Download the sample file"
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {sample.id}.py
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Step 2: Create folder */}
|
||||
<SetupStep
|
||||
number={2}
|
||||
title="Create a project folder"
|
||||
description="Create a dedicated folder for this sample and move the downloaded file there:"
|
||||
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Step 3: Environment variables (conditional) */}
|
||||
{hasEnvVars && (
|
||||
<SetupStep
|
||||
number={3}
|
||||
title="Set up environment variables"
|
||||
description="Create a .env file in the project folder with these required variables:"
|
||||
code={sample.requiredEnvVars!
|
||||
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
|
||||
.join("\n\n")}
|
||||
copyable
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4: Run DevUI */}
|
||||
<SetupStep
|
||||
number={4 + stepOffset}
|
||||
title="Run with DevUI"
|
||||
description="Navigate to the folder and start DevUI:"
|
||||
code={`cd ~/my-agents/${sample.id}\ndevui .`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Alternative: Direct run */}
|
||||
<Alert>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
|
||||
<CodeBlock copyable>
|
||||
{`from ${sample.id} import ${sample.type}
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
response = await ${sample.type}.run("Hello!")
|
||||
print(response)
|
||||
|
||||
asyncio.run(main())`}
|
||||
</CodeBlock>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Help links */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={sample.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Source
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* CheckpointInfoModal - Timeline view of workflow checkpoints
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Clock,
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Package,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type { CheckpointItem, WorkflowSession, FullCheckpoint, PendingRequestInfoEvent } from "@/types";
|
||||
|
||||
interface CheckpointInfoModalProps {
|
||||
session: WorkflowSession | null;
|
||||
checkpoints: CheckpointItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CheckpointInfoModal({
|
||||
session,
|
||||
checkpoints,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CheckpointInfoModalProps) {
|
||||
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
|
||||
const [fullCheckpoint, setFullCheckpoint] = useState<FullCheckpoint | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [jsonExpanded, setJsonExpanded] = useState(true);
|
||||
|
||||
// Select first checkpoint when modal opens or checkpoints change
|
||||
useEffect(() => {
|
||||
if (open && checkpoints.length > 0) {
|
||||
// Only reset selection if current selection is invalid
|
||||
const currentSelectionValid = checkpoints.some(
|
||||
cp => cp.checkpoint_id === selectedCheckpointId
|
||||
);
|
||||
if (!currentSelectionValid) {
|
||||
setSelectedCheckpointId(checkpoints[0].checkpoint_id);
|
||||
}
|
||||
}
|
||||
}, [open, checkpoints]);
|
||||
|
||||
// Load full checkpoint details
|
||||
useEffect(() => {
|
||||
if (!selectedCheckpointId || !session) return;
|
||||
|
||||
const loadDetails = async () => {
|
||||
// Don't clear the previous checkpoint to avoid UI flash
|
||||
setLoading(true);
|
||||
try {
|
||||
const item = await apiClient.getConversationItem(
|
||||
session.conversation_id,
|
||||
`checkpoint_${selectedCheckpointId}`
|
||||
);
|
||||
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to load checkpoint:", error);
|
||||
setFullCheckpoint(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDetails();
|
||||
}, [selectedCheckpointId, session]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const selectedCheckpoint = checkpoints.find(
|
||||
(cp) => cp.checkpoint_id === selectedCheckpointId
|
||||
);
|
||||
|
||||
const executorIds = fullCheckpoint?.state?._executor_state
|
||||
? Object.keys(fullCheckpoint.state._executor_state)
|
||||
: [];
|
||||
const messageExecutors = fullCheckpoint?.messages
|
||||
? Object.keys(fullCheckpoint.messages)
|
||||
: [];
|
||||
|
||||
// Format checkpoint size for display
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return "";
|
||||
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1) {
|
||||
return `${bytes} B`;
|
||||
} else if (kb < 1024) {
|
||||
return `${kb.toFixed(1)} KB`;
|
||||
} else {
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<DialogTitle>{session.metadata.name}</DialogTitle>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2 max-w-2xl">
|
||||
This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.
|
||||
</div>
|
||||
</div>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Main Content - Timeline + Details */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
{/* Timeline Sidebar */}
|
||||
<div className="w-80 border-r flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-2">
|
||||
{checkpoints.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
No checkpoints yet
|
||||
</div>
|
||||
) : (
|
||||
checkpoints.map((checkpoint, index) => {
|
||||
const isSelected = checkpoint.checkpoint_id === selectedCheckpointId;
|
||||
const hasHil = checkpoint.metadata.has_pending_hil;
|
||||
|
||||
return (
|
||||
<div key={checkpoint.checkpoint_id} className="relative">
|
||||
<button
|
||||
onClick={() => setSelectedCheckpointId(checkpoint.checkpoint_id)}
|
||||
className={cn(
|
||||
"relative w-full text-left p-3 rounded-lg border transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 border-primary"
|
||||
: "hover:bg-muted/50 border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Timeline Dot */}
|
||||
<div className="flex flex-col items-center pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full z-10",
|
||||
hasHil
|
||||
? "bg-blue-500 ring-2 ring-blue-500/20"
|
||||
: "bg-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checkpoint Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{checkpoint.metadata.iteration_count === 0 ? "Initial State" : `Step ${checkpoint.metadata.iteration_count}`}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/70" title={checkpoint.checkpoint_id}>
|
||||
{checkpoint.checkpoint_id.slice(0, 8)}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
{hasHil && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
|
||||
{checkpoint.metadata.pending_hil_count} HIL
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span>{new Date(checkpoint.timestamp).toLocaleTimeString()}</span>
|
||||
{checkpoint.metadata.size_bytes && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatSize(checkpoint.metadata.size_bytes)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Connecting Line - positioned absolutely */}
|
||||
{index < checkpoints.length - 1 && (
|
||||
<div className="absolute left-[18px] top-[30px] w-px h-[calc(100%+8px)] bg-border" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Details Panel */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{!fullCheckpoint && !loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Select a checkpoint to view details
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-6 relative">
|
||||
{/* Loading overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between pb-4 border-b">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{selectedCheckpoint?.metadata.iteration_count === 0
|
||||
? "Initial State"
|
||||
: `Step ${selectedCheckpoint?.metadata.iteration_count}`}
|
||||
</span>
|
||||
{selectedCheckpoint?.metadata.size_bytes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
• {formatSize(selectedCheckpoint.metadata.size_bytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedCheckpoint &&
|
||||
new Date(selectedCheckpoint.timestamp).toLocaleString()}
|
||||
</div>
|
||||
{selectedCheckpoint && (
|
||||
<div className="text-xs font-mono text-muted-foreground/70 mt-1">
|
||||
ID: {selectedCheckpoint.checkpoint_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedCheckpoint?.metadata.has_pending_hil && (
|
||||
<Badge variant="secondary">
|
||||
{selectedCheckpoint.metadata.pending_hil_count} HIL Pending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Executors */}
|
||||
{executorIds.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Active Executors ({executorIds.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{executorIds.map((execId) => (
|
||||
<Badge key={execId} variant="outline" className="font-mono text-xs">
|
||||
{execId}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messageExecutors.length > 0 && fullCheckpoint && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Messages
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{messageExecutors.map((execId) => {
|
||||
const count = (fullCheckpoint.messages[execId] as unknown[])?.length;
|
||||
return (
|
||||
<div key={execId} className="bg-muted/50 p-3 rounded-lg">
|
||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
||||
{execId}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{count} message{count !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HIL Requests */}
|
||||
{fullCheckpoint?.pending_request_info_events &&
|
||||
Object.keys(fullCheckpoint.pending_request_info_events).length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Pending HIL Requests (
|
||||
{Object.keys(fullCheckpoint.pending_request_info_events).length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(fullCheckpoint.pending_request_info_events).map(
|
||||
([reqId, reqData]: [string, PendingRequestInfoEvent]) => (
|
||||
<div
|
||||
key={reqId}
|
||||
className="bg-muted/50 border border-border p-3 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded">
|
||||
{reqId.slice(0, 24)}...
|
||||
</code>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{reqData.source_executor_id}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Request:</span>{" "}
|
||||
<code className="bg-background px-1 py-0.5 rounded">
|
||||
{reqData.request_type?.split(".").pop() || reqData.request_type}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Response:</span>{" "}
|
||||
<code className="bg-background px-1 py-0.5 rounded">
|
||||
{reqData.response_type?.split(".").pop() || reqData.response_type}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow State */}
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-3">Workflow State</div>
|
||||
{fullCheckpoint?.state && Object.keys(fullCheckpoint.state).filter(
|
||||
(k) => k !== "_executor_state"
|
||||
).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.keys(fullCheckpoint.state)
|
||||
.filter((k) => k !== "_executor_state")
|
||||
.map((key) => (
|
||||
<Badge key={key} variant="secondary" className="font-mono text-xs">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No custom state</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON (Collapsible) */}
|
||||
<div className="border-t pt-6">
|
||||
<button
|
||||
onClick={() => setJsonExpanded(!jsonExpanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
|
||||
>
|
||||
{jsonExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Raw JSON
|
||||
</button>
|
||||
{jsonExpanded && (
|
||||
<pre className="mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto">
|
||||
{JSON.stringify(fullCheckpoint, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+714
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* ExecutionTimeline - Vertical timeline showing workflow executor runs
|
||||
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HilTimelineItem } from "./hil-timeline-item";
|
||||
import { RunWorkflowButton } from "./run-workflow-button";
|
||||
import { ChatMessageInput } from "@/components/ui/chat-message-input";
|
||||
import { isChatMessageSchema } from "@/utils/workflow-utils";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Check,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
|
||||
import type { ResponseInputContent } from "@/types/agent-framework";
|
||||
import type { ExecutorState } from "./executor-node";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
interface ExecutorRun {
|
||||
executorId: string;
|
||||
executorName: string;
|
||||
itemId: string; // Unique ID for this specific run
|
||||
state: ExecutorState;
|
||||
output: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
runNumber: number; // For multiple runs of same executor
|
||||
}
|
||||
|
||||
interface ExecutionTimelineProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
itemOutputs: Record<string, string>;
|
||||
currentExecutorId: string | null;
|
||||
isStreaming: boolean;
|
||||
onExecutorClick?: (executorId: string) => void;
|
||||
selectedExecutorId?: string | null;
|
||||
workflowResult?: string;
|
||||
// HIL support
|
||||
pendingHilRequests?: Array<{
|
||||
request_id: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: import("@/types").JSONSchemaProperty;
|
||||
}>;
|
||||
hilResponses?: Record<string, Record<string, unknown>>;
|
||||
onHilResponseChange?: (requestId: string, values: Record<string, unknown>) => void;
|
||||
onHilSubmit?: () => void;
|
||||
isSubmittingHil?: boolean;
|
||||
// Workflow control props for bottom bar
|
||||
inputSchema?: JSONSchemaProperty;
|
||||
onRun?: (data: Record<string, unknown>, checkpointId?: string) => void;
|
||||
onCancel?: () => void;
|
||||
isCancelling?: boolean;
|
||||
workflowState?: "ready" | "running" | "completed" | "error" | "cancelled";
|
||||
wasCancelled?: boolean;
|
||||
checkpoints?: import("@/types").CheckpointItem[];
|
||||
}
|
||||
|
||||
function getStateIcon(state: ExecutorState, isStreaming: boolean = true) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return <Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${isStreaming ? 'animate-spin' : ''}`} />;
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
|
||||
case "cancelled":
|
||||
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
|
||||
default:
|
||||
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStateBadgeClass(state: ExecutorState) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
|
||||
case "completed":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
|
||||
case "failed":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
|
||||
case "cancelled":
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
|
||||
default:
|
||||
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageText(item: unknown): string {
|
||||
const content = (item as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
return content
|
||||
?.filter((content) => content.type === "output_text" && content.text)
|
||||
.map((content) => content.text)
|
||||
.join("\n") ?? "";
|
||||
}
|
||||
|
||||
function ExecutorRunItem({
|
||||
run,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
isSelected,
|
||||
isStreaming,
|
||||
}: {
|
||||
run: ExecutorRun;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const hasOutput = run.output.trim().length > 0;
|
||||
const canExpand = hasOutput || run.error;
|
||||
const outputRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Auto-scroll output to bottom when content changes (during streaming)
|
||||
useEffect(() => {
|
||||
if (isExpanded && run.state === "running" && outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [run.output, isExpanded, run.state]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
|
||||
: "border-border hover:border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{/* Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
if (canExpand) onToggle();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
|
||||
<div className="w-3 text-muted-foreground">
|
||||
{canExpand && (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>{getStateIcon(run.state, isStreaming)}</div>
|
||||
<span className="font-medium text-sm truncate overflow-hidden">
|
||||
{run.executorName}
|
||||
</span>
|
||||
{run.runNumber > 1 ? (
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
Run #{run.runNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs border ${getStateBadgeClass(run.state)}`}
|
||||
>
|
||||
{run.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && canExpand && (
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
{run.error ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
|
||||
{run.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Output:
|
||||
</div>
|
||||
<pre
|
||||
ref={outputRef}
|
||||
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
|
||||
>
|
||||
{run.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionTimeline({
|
||||
events,
|
||||
itemOutputs,
|
||||
currentExecutorId,
|
||||
isStreaming,
|
||||
onExecutorClick,
|
||||
selectedExecutorId,
|
||||
workflowResult,
|
||||
pendingHilRequests = [],
|
||||
hilResponses = {},
|
||||
onHilResponseChange,
|
||||
onHilSubmit,
|
||||
isSubmittingHil = false,
|
||||
// New props
|
||||
inputSchema,
|
||||
onRun,
|
||||
onCancel,
|
||||
isCancelling = false,
|
||||
workflowState = "ready",
|
||||
wasCancelled = false,
|
||||
checkpoints = [],
|
||||
}: ExecutionTimelineProps) {
|
||||
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const lastScrolledRunRef = useRef<string | null>(null);
|
||||
const timelineEndRef = useRef<HTMLDivElement>(null);
|
||||
const hilFormRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Force re-render when streaming to show updated outputs from itemOutputs ref
|
||||
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
|
||||
// This polling approach ensures the UI updates during streaming. Could be optimized by:
|
||||
// 1. Converting itemOutputs to state (increases re-renders)
|
||||
// 2. Using requestAnimationFrame instead of setInterval
|
||||
// 3. Having parent component trigger updates via callback
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
}, 100); // Update 10 times per second during streaming
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Process events to extract executor runs - memoized to prevent recalculation
|
||||
const { executorRuns, executorRunCount } = useMemo(() => {
|
||||
const runs: ExecutorRun[] = [];
|
||||
const runCount = new Map<string, number>();
|
||||
|
||||
events.forEach((event) => {
|
||||
// Extract UI timestamp (captured when event arrived, won't change on re-render)
|
||||
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? event._uiTimestamp * 1000
|
||||
: Date.now();
|
||||
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added") {
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemAddedEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
|
||||
const executorId = String(item.executor_id);
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message items from Magentic agents
|
||||
const metadata = item.metadata as {
|
||||
agent_id?: string;
|
||||
executor_id?: string;
|
||||
source?: string;
|
||||
workflow_output_kind?: string;
|
||||
} | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const executorId = metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
|
||||
const executorId = metadata.executor_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: item.status === "completed" ? "completed" : "running",
|
||||
output: itemOutputs[itemId] || getMessageText(item),
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion events
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
|
||||
const itemId = item.id;
|
||||
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state =
|
||||
item.status === "completed"
|
||||
? "completed"
|
||||
: item.status === "failed"
|
||||
? "failed"
|
||||
: "completed";
|
||||
// Use item-specific output, not executor-wide output
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
if (item.status === "failed" && "error" in item && item.error) {
|
||||
existingRun.error = String(item.error);
|
||||
}
|
||||
}
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message completion from Magentic agents
|
||||
const metadata = item.metadata as {
|
||||
agent_id?: string;
|
||||
executor_id?: string;
|
||||
source?: string;
|
||||
workflow_output_kind?: string;
|
||||
} | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
}
|
||||
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || getMessageText(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
|
||||
if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
|
||||
const executorId = data.executor_id;
|
||||
if (!executorId) return;
|
||||
|
||||
const eventType = data.event_type;
|
||||
|
||||
if (eventType === "ExecutorInvokedEvent") {
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
// Create synthetic item ID using the run counter for guaranteed uniqueness.
|
||||
// Using uiTimestamp here caused collisions when the same executor ran
|
||||
// twice within the same second (both fallback entries would share an ID).
|
||||
const syntheticItemId = `fallback_${executorId}_run${runNumber}`;
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId: syntheticItemId,
|
||||
state: "running",
|
||||
output: itemOutputs[syntheticItemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (eventType === "ExecutorCompletedEvent") {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "completed";
|
||||
existingRun.output = itemOutputs[existingRun.itemId] || "";
|
||||
}
|
||||
} else if (
|
||||
eventType?.includes("Error") ||
|
||||
eventType?.includes("Failed")
|
||||
) {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "failed";
|
||||
existingRun.error =
|
||||
typeof data.data === "string" ? data.data : "Execution failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update outputs for running executors using item-specific outputs
|
||||
// This ensures each run gets its own output, even for multiple runs of the same executor
|
||||
runs.forEach((run) => {
|
||||
if (run.state === "running" && itemOutputs[run.itemId]) {
|
||||
run.output = itemOutputs[run.itemId];
|
||||
}
|
||||
});
|
||||
|
||||
return { executorRuns: runs, executorRunCount: runCount };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [events, itemOutputs, updateTrigger]);
|
||||
|
||||
// Auto-expand running executors
|
||||
useEffect(() => {
|
||||
if (currentExecutorId) {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [currentExecutorId, executorRunCount]);
|
||||
|
||||
// Auto-scroll to newest executor when it appears or changes
|
||||
useEffect(() => {
|
||||
if (executorRuns.length > 0 && isStreaming) {
|
||||
const latestRun = executorRuns[executorRuns.length - 1];
|
||||
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
|
||||
|
||||
// Only scroll if this is a new run we haven't scrolled to yet
|
||||
if (latestRunKey !== lastScrolledRunRef.current) {
|
||||
lastScrolledRunRef.current = latestRunKey;
|
||||
|
||||
// Scroll to the end of the timeline
|
||||
if (timelineEndRef.current) {
|
||||
timelineEndRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [executorRuns, isStreaming]);
|
||||
|
||||
// Auto-scroll to show workflow result when it appears (after streaming completes)
|
||||
useEffect(() => {
|
||||
if (workflowResult && !isStreaming && timelineEndRef.current) {
|
||||
// Small delay to ensure the result card is rendered before scrolling
|
||||
setTimeout(() => {
|
||||
timelineEndRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [workflowResult, isStreaming]);
|
||||
|
||||
// Auto-scroll when streaming ends to show final executor state
|
||||
// (Ensures last executor's completion is visible, even if no workflow result)
|
||||
useEffect(() => {
|
||||
// Only scroll when streaming ends AND no HIL requests are pending
|
||||
// (HIL has its own scroll handler below)
|
||||
if (!isStreaming && executorRuns.length > 0 && pendingHilRequests.length === 0) {
|
||||
setTimeout(() => {
|
||||
timelineEndRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [isStreaming, pendingHilRequests.length, executorRuns.length]);
|
||||
|
||||
// Auto-scroll to HIL form when it appears
|
||||
useEffect(() => {
|
||||
if (pendingHilRequests.length > 0 && hilFormRef.current) {
|
||||
// Small delay to ensure the form is rendered
|
||||
setTimeout(() => {
|
||||
hilFormRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
// Add highlight animation
|
||||
hilFormRef.current?.classList.add('highlight-attention');
|
||||
setTimeout(() => {
|
||||
hilFormRef.current?.classList.remove('highlight-attention');
|
||||
}, 1000);
|
||||
}, 100);
|
||||
}
|
||||
}, [pendingHilRequests.length]);
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const text = executorRuns
|
||||
.map((run) => {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
|
||||
const content = run.error || run.output || "(no output)";
|
||||
return `${header}\n${content}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Execution Timeline</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{executorRuns.length}
|
||||
</Badge>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{executorRuns.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAll}
|
||||
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-2">
|
||||
{executorRuns.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
{isStreaming ? "Workflow is running..." : "Ready to run workflow"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{executorRuns.map((run, index) => {
|
||||
const runKey = `${run.executorId}-${run.runNumber}`;
|
||||
return (
|
||||
<ExecutorRunItem
|
||||
key={`${runKey}-${index}`}
|
||||
run={run}
|
||||
isExpanded={expandedRuns.has(runKey)}
|
||||
onToggle={() => {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(runKey)) {
|
||||
next.delete(runKey);
|
||||
} else {
|
||||
next.add(runKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onClick={() => onExecutorClick?.(run.executorId)}
|
||||
isSelected={selectedExecutorId === run.executorId}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* HIL Request Items */}
|
||||
{pendingHilRequests.length > 0 && (
|
||||
<div ref={hilFormRef} data-hil-form className="transition-all duration-300">
|
||||
{pendingHilRequests.map((request) => (
|
||||
<HilTimelineItem
|
||||
key={request.request_id}
|
||||
request={request}
|
||||
response={hilResponses[request.request_id] || {}}
|
||||
onResponseChange={(values) => onHilResponseChange?.(request.request_id, values)}
|
||||
onSubmit={() => onHilSubmit?.()}
|
||||
isSubmitting={isSubmittingHil}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Workflow final output card */}
|
||||
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && !wasCancelled && (
|
||||
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
|
||||
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">Workflow Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Final Output:
|
||||
</div>
|
||||
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
|
||||
{workflowResult}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Workflow cancelled card */}
|
||||
{wasCancelled && !isStreaming && (
|
||||
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
|
||||
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Execution stopped by user</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Invisible element at the end for scroll target */}
|
||||
<div ref={timelineEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
|
||||
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
|
||||
<div className="border-t p-3 bg-background flex-shrink-0">
|
||||
{inputSchema && isChatMessageSchema(inputSchema) ? (
|
||||
<ChatMessageInput
|
||||
onSubmit={async (content: ResponseInputContent[]) => {
|
||||
// Wrap in OpenAI message format (same as run-workflow-button modal)
|
||||
const openaiInput = [
|
||||
{ type: "message", role: "user", content },
|
||||
];
|
||||
onRun?.(openaiInput as unknown as Record<string, unknown>);
|
||||
}}
|
||||
isSubmitting={workflowState === "running"}
|
||||
isStreaming={workflowState === "running"}
|
||||
onCancel={onCancel}
|
||||
isCancelling={isCancelling}
|
||||
placeholder="Message workflow..."
|
||||
showFileUpload={true}
|
||||
entityName="workflow"
|
||||
/>
|
||||
) : (
|
||||
<RunWorkflowButton
|
||||
inputSchema={inputSchema}
|
||||
onRun={onRun || (() => {})}
|
||||
onCancel={onCancel}
|
||||
isSubmitting={workflowState === "running"}
|
||||
isCancelling={isCancelling}
|
||||
workflowState={workflowState}
|
||||
checkpoints={checkpoints}
|
||||
showCheckpoints={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
export type ExecutorState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface ExecutorNodeData extends Record<string, unknown> {
|
||||
executorId: string;
|
||||
executorType?: string;
|
||||
name?: string;
|
||||
state: ExecutorState;
|
||||
inputData?: unknown;
|
||||
outputData?: unknown;
|
||||
error?: string;
|
||||
isSelected?: boolean;
|
||||
isStartNode?: boolean;
|
||||
isEndNode?: boolean;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return {
|
||||
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
|
||||
glow: "shadow-lg shadow-[#643FB2]/20",
|
||||
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
borderColor: "border-green-500 dark:border-green-400",
|
||||
glow: "shadow-lg shadow-green-500/20",
|
||||
badgeColor: "bg-green-500 dark:bg-green-400",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
borderColor: "border-red-500 dark:border-red-400",
|
||||
glow: "shadow-lg shadow-red-500/20",
|
||||
badgeColor: "bg-red-500 dark:bg-red-400",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
borderColor: "border-orange-500 dark:border-orange-400",
|
||||
glow: "shadow-lg shadow-orange-500/20",
|
||||
badgeColor: "bg-orange-500 dark:bg-orange-400",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
borderColor: "border-gray-300 dark:border-gray-600",
|
||||
glow: "",
|
||||
badgeColor: "bg-gray-400 dark:bg-gray-500",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const nodeData = data as ExecutorNodeData;
|
||||
const config = getExecutorStateConfig(nodeData.state);
|
||||
const [isOutputExpanded, setIsOutputExpanded] = useState(false);
|
||||
|
||||
const hasOutput = nodeData.outputData || nodeData.error;
|
||||
const isRunning = nodeData.state === "running";
|
||||
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
|
||||
|
||||
// Determine handle positions based on layout direction
|
||||
const isVertical = nodeData.layoutDirection === "TB";
|
||||
const targetPosition = isVertical ? Position.Top : Position.Left;
|
||||
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
|
||||
|
||||
// Helper to render output/error details when expanded
|
||||
const renderDataDetails = () => {
|
||||
if (nodeData.error && typeof nodeData.error === "string") {
|
||||
const truncatedError = truncateText(nodeData.error, 200);
|
||||
return (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto">
|
||||
{truncatedError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeData.outputData) {
|
||||
try {
|
||||
const outputStr =
|
||||
typeof nodeData.outputData === "string"
|
||||
? nodeData.outputData
|
||||
: JSON.stringify(nodeData.outputData, null, 2);
|
||||
return (
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border">
|
||||
[Unable to display output]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
|
||||
config.borderColor,
|
||||
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Small circular handles - always render both to support any edge configuration */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={targetPosition}
|
||||
id="target"
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={sourcePosition}
|
||||
id="source"
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 relative">
|
||||
{/* Icon container with dark background */}
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
|
||||
{nodeData.isStartNode ? (
|
||||
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
|
||||
) : (
|
||||
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{isRunning && (
|
||||
<Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${shouldAnimate ? 'animate-spin' : ''} flex-shrink-0`} />
|
||||
)}
|
||||
</div>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible output section */}
|
||||
{hasOutput && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOutputExpanded(!isOutputExpanded);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{isOutputExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
<span>{nodeData.error ? "Show error" : "Show output"}</span>
|
||||
</button>
|
||||
{isOutputExpanded && (
|
||||
<div className="mt-2">
|
||||
{renderDataDetails()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running animation overlay */}
|
||||
{isRunning && (
|
||||
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExecutorNode.displayName = "ExecutorNode";
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* HilTimelineItem - Inline HIL request form for the ExecutionTimeline
|
||||
* Shows HIL requests as part of the workflow execution flow
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
export interface HilRequest {
|
||||
request_id: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: JSONSchemaProperty;
|
||||
}
|
||||
|
||||
interface HilTimelineItemProps {
|
||||
request: HilRequest;
|
||||
response: Record<string, unknown>;
|
||||
onResponseChange: (values: Record<string, unknown>) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function HilTimelineItem({
|
||||
request,
|
||||
response,
|
||||
onResponseChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: HilTimelineItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const handleResponseChange = (values: Record<string, unknown>) => {
|
||||
onResponseChange(values);
|
||||
};
|
||||
|
||||
const isValid = validateSchemaForm(request.request_schema, response);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{/* Main content - removed icon and adjusted layout */}
|
||||
<div>
|
||||
{/* Content area - removed pb-4 padding */}
|
||||
<div className="flex-1">
|
||||
<div className="border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-orange-900 dark:text-orange-100">
|
||||
Workflow needs your input
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300"
|
||||
>
|
||||
{request.request_id.slice(0, 8)}
|
||||
</Badge>
|
||||
{!isExpanded && (
|
||||
<span className="text-xs text-orange-600 dark:text-orange-400 animate-pulse">
|
||||
Click to respond
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isSubmitting && (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Submitting...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Request context - scrollable */}
|
||||
{Object.keys(request.request_data).length > 0 && (
|
||||
<div className="bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Context
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 pr-2">
|
||||
{Object.entries(request.request_data)
|
||||
.filter(
|
||||
([key]) =>
|
||||
!["request_id", "source_executor_id"].includes(key)
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{key}:
|
||||
</span>{" "}
|
||||
<span className="text-foreground break-all">
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description hint */}
|
||||
{request.request_schema?.description && (
|
||||
<div className="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
What's needed:
|
||||
</p>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
{request.request_schema.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<div className="space-y-3">
|
||||
<SchemaFormRenderer
|
||||
schema={request.request_schema}
|
||||
values={response}
|
||||
onChange={handleResponseChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<Button
|
||||
size="default"
|
||||
onClick={onSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Submit Response
|
||||
</Button>
|
||||
{!isValid && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Please fill in all required fields
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Workflow Feature - Exports
|
||||
*/
|
||||
|
||||
export { WorkflowView } from "./workflow-view";
|
||||
export { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
export { WorkflowFlow } from "./workflow-flow";
|
||||
export { WorkflowInputForm } from "./workflow-input-form";
|
||||
export { ExecutorNode } from "./executor-node";
|
||||
export {
|
||||
SchemaFormRenderer,
|
||||
validateSchemaForm,
|
||||
filterEmptyOptionalFields,
|
||||
resolveSchemaType,
|
||||
isShortField,
|
||||
shouldFieldBeTextarea,
|
||||
getFieldColumnSpan,
|
||||
detectChatMessagePattern,
|
||||
} from "./schema-form-renderer";
|
||||
export { CheckpointInfoModal } from "./checkpoint-info-modal";
|
||||
export { RunWorkflowButton } from "./run-workflow-button";
|
||||
export type { RunWorkflowButtonProps } from "./run-workflow-button";
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* RunWorkflowButton - Shared component for running workflows with checkpoint support
|
||||
* Features: Split button with dropdown for checkpoint selection, input validation, modal dialog
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WorkflowInputForm } from "./workflow-input-form";
|
||||
import { ChatMessageInput } from "@/components/ui/chat-message-input";
|
||||
import { isChatMessageSchema } from "@/utils/workflow-utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Square,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { JSONSchemaProperty, CheckpointItem } from "@/types";
|
||||
import type { ResponseInputContent } from "@/types/agent-framework";
|
||||
|
||||
export interface RunWorkflowButtonProps {
|
||||
inputSchema?: JSONSchemaProperty;
|
||||
onRun: (data: Record<string, unknown>, checkpointId?: string) => void;
|
||||
onCancel?: () => void;
|
||||
isSubmitting: boolean;
|
||||
isCancelling?: boolean;
|
||||
workflowState: "ready" | "running" | "completed" | "error" | "cancelled";
|
||||
checkpoints?: CheckpointItem[];
|
||||
// Optional prop to control whether to show checkpoints dropdown
|
||||
showCheckpoints?: boolean;
|
||||
}
|
||||
|
||||
export function RunWorkflowButton({
|
||||
inputSchema,
|
||||
onRun,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
isCancelling = false,
|
||||
workflowState,
|
||||
checkpoints = [],
|
||||
showCheckpoints = true,
|
||||
}: RunWorkflowButtonProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
// Handle escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && showModal) {
|
||||
setShowModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showModal) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [showModal]);
|
||||
|
||||
// Analyze input requirements
|
||||
const inputAnalysis = useMemo(() => {
|
||||
// Check if this is a Message schema (for AgentExecutor workflows)
|
||||
const isChatMessage = isChatMessageSchema(inputSchema);
|
||||
|
||||
if (!inputSchema)
|
||||
return {
|
||||
needsInput: false,
|
||||
hasDefaults: false,
|
||||
fieldCount: 0,
|
||||
canRunDirectly: true,
|
||||
isChatMessage: false,
|
||||
};
|
||||
|
||||
if (inputSchema.type === "string") {
|
||||
return {
|
||||
needsInput: !inputSchema.default,
|
||||
hasDefaults: !!inputSchema.default,
|
||||
fieldCount: 1,
|
||||
canRunDirectly: !!inputSchema.default,
|
||||
isChatMessage: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputSchema.type === "object" && inputSchema.properties) {
|
||||
const properties = inputSchema.properties;
|
||||
const fields = Object.entries(properties);
|
||||
const fieldsWithDefaults = fields.filter(
|
||||
([, schema]: [string, JSONSchemaProperty]) =>
|
||||
schema.default !== undefined ||
|
||||
(schema.enum && schema.enum.length > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
needsInput: fields.length > 0,
|
||||
hasDefaults: fieldsWithDefaults.length > 0,
|
||||
fieldCount: fields.length,
|
||||
canRunDirectly:
|
||||
fields.length === 0 || fieldsWithDefaults.length === fields.length,
|
||||
isChatMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
needsInput: false,
|
||||
hasDefaults: false,
|
||||
fieldCount: 0,
|
||||
canRunDirectly: true,
|
||||
isChatMessage: false,
|
||||
};
|
||||
}, [inputSchema]);
|
||||
|
||||
const handleDirectRun = () => {
|
||||
if (workflowState === "running" && onCancel) {
|
||||
onCancel();
|
||||
} else if (inputAnalysis.canRunDirectly) {
|
||||
// Build default data
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
|
||||
if (inputSchema?.type === "string" && inputSchema.default) {
|
||||
defaultData.input = inputSchema.default;
|
||||
} else if (inputSchema?.type === "object" && inputSchema.properties) {
|
||||
Object.entries(inputSchema.properties).forEach(
|
||||
([key, schema]: [string, JSONSchemaProperty]) => {
|
||||
if (schema.default !== undefined) {
|
||||
defaultData[key] = schema.default;
|
||||
} else if (schema.enum && schema.enum.length > 0) {
|
||||
defaultData[key] = schema.enum[0];
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onRun(defaultData);
|
||||
} else {
|
||||
setShowModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunFromCheckpoint = (checkpointId: string) => {
|
||||
if (inputAnalysis.canRunDirectly) {
|
||||
// Build default data
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
|
||||
if (inputSchema?.type === "string" && inputSchema.default) {
|
||||
defaultData.input = inputSchema.default;
|
||||
} else if (inputSchema?.type === "object" && inputSchema.properties) {
|
||||
Object.entries(inputSchema.properties).forEach(
|
||||
([key, schema]: [string, JSONSchemaProperty]) => {
|
||||
if (schema.default !== undefined) {
|
||||
defaultData[key] = schema.default;
|
||||
} else if (schema.enum && schema.enum.length > 0) {
|
||||
defaultData[key] = schema.enum[0];
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onRun(defaultData, checkpointId);
|
||||
} else {
|
||||
// TODO: Pass checkpoint ID to modal for custom inputs
|
||||
setShowModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const hasCheckpoints = showCheckpoints && checkpoints.length > 0;
|
||||
|
||||
// Format checkpoint size for display
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return "";
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1) {
|
||||
return `${bytes} B`;
|
||||
} else if (kb < 1024) {
|
||||
return `${kb.toFixed(1)} KB`;
|
||||
} else {
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
};
|
||||
|
||||
// Build the button content based on state
|
||||
const getButtonContent = () => {
|
||||
const icon = isCancelling ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : workflowState === "running" && onCancel ? (
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
) : workflowState === "running" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : workflowState === "error" ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
|
||||
<Settings className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
);
|
||||
|
||||
const text = isCancelling
|
||||
? "Stopping..."
|
||||
: workflowState === "running" && onCancel
|
||||
? "Stop"
|
||||
: workflowState === "running"
|
||||
? "Running..."
|
||||
: workflowState === "completed"
|
||||
? "Run Again"
|
||||
: workflowState === "error"
|
||||
? "Retry"
|
||||
: inputAnalysis.fieldCount === 0
|
||||
? "Run Workflow"
|
||||
: inputAnalysis.canRunDirectly
|
||||
? "Run Workflow"
|
||||
: "Configure & Run";
|
||||
|
||||
return { icon, text };
|
||||
};
|
||||
|
||||
const { icon, text } = getButtonContent();
|
||||
const isDisabled = (workflowState === "running" && !onCancel) || isCancelling;
|
||||
const buttonVariant = workflowState === "error" ? "destructive" : "default";
|
||||
|
||||
// Unified layout for both variants
|
||||
const renderButton = () => {
|
||||
// Always show split button if there are checkpoints OR if inputs need configuration
|
||||
const showDropdown = hasCheckpoints || inputAnalysis.needsInput;
|
||||
|
||||
if (!showDropdown) {
|
||||
// Simple button - no dropdown needed
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDirectRun}
|
||||
disabled={isDisabled}
|
||||
variant={buttonVariant}
|
||||
className="gap-2 w-full"
|
||||
title={
|
||||
workflowState === "running" && onCancel
|
||||
? "Stop workflow execution"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Split button with dropdown
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className="flex w-full">
|
||||
<Button
|
||||
onClick={handleDirectRun}
|
||||
disabled={isDisabled}
|
||||
variant={buttonVariant}
|
||||
className="gap-2 rounded-r-none flex-1"
|
||||
title={
|
||||
workflowState === "running" && onCancel
|
||||
? "Stop workflow execution"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
variant={buttonVariant}
|
||||
className="rounded-l-none border-l-0 px-2"
|
||||
title="More options"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 max-h-[400px] overflow-y-auto"
|
||||
>
|
||||
{/* Run Fresh option - only show when checkpoints are enabled */}
|
||||
{hasCheckpoints && (
|
||||
<DropdownMenuItem onClick={handleDirectRun}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Run Fresh
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Configure inputs option */}
|
||||
{inputAnalysis.needsInput && (
|
||||
<DropdownMenuItem onClick={() => setShowModal(true)}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configure Inputs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Checkpoint options */}
|
||||
{hasCheckpoints && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
Resume from checkpoint
|
||||
</div>
|
||||
{checkpoints.map((checkpoint, index) => (
|
||||
<DropdownMenuItem
|
||||
key={checkpoint.checkpoint_id}
|
||||
onClick={() =>
|
||||
handleRunFromCheckpoint(checkpoint.checkpoint_id)
|
||||
}
|
||||
className="flex flex-col items-start py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<RefreshCw className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="font-medium">
|
||||
{checkpoint.metadata.iteration_count === 0
|
||||
? "Initial State"
|
||||
: `Step ${checkpoint.metadata.iteration_count}`}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1 ml-auto"
|
||||
>
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(checkpoint.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
{checkpoint.metadata.size_bytes && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{formatSize(checkpoint.metadata.size_bytes)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderButton()}
|
||||
|
||||
{/* Modal for input configuration */}
|
||||
{inputSchema && (
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-8 pt-6">
|
||||
<DialogTitle>Configure Workflow Inputs</DialogTitle>
|
||||
<DialogClose onClose={() => setShowModal(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-8 py-4 border-b flex-shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">Input Type:</span>
|
||||
<Badge variant="secondary">
|
||||
{inputAnalysis.isChatMessage
|
||||
? "Chat Message"
|
||||
: inputSchema.type === "string"
|
||||
? "Simple Text"
|
||||
: "Structured Data"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 px-8">
|
||||
{inputAnalysis.isChatMessage ? (
|
||||
<ChatMessageInput
|
||||
onSubmit={async (content: ResponseInputContent[]) => {
|
||||
// Wrap in OpenAI message format (same structure as agent-view)
|
||||
// This preserves multimodal content (images, files) for the backend
|
||||
const openaiInput = [
|
||||
{ type: "message", role: "user", content },
|
||||
];
|
||||
onRun(openaiInput as unknown as Record<string, unknown>);
|
||||
setShowModal(false);
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
placeholder="Enter your message..."
|
||||
entityName="workflow"
|
||||
showFileUpload={true}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowInputForm
|
||||
inputSchema={inputSchema}
|
||||
inputTypeName="Input"
|
||||
onSubmit={(values) => {
|
||||
onRun(values as Record<string, unknown>);
|
||||
setShowModal(false);
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
className="embedded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
// ============================================================================
|
||||
// Field Type Detection Helpers (exported for reuse)
|
||||
// ============================================================================
|
||||
|
||||
export function isShortField(fieldName: string): boolean {
|
||||
const shortFieldNames = [
|
||||
"name",
|
||||
"title",
|
||||
"id",
|
||||
"key",
|
||||
"label",
|
||||
"type",
|
||||
"status",
|
||||
"tag",
|
||||
"category",
|
||||
"code",
|
||||
"username",
|
||||
"password",
|
||||
"email",
|
||||
];
|
||||
return shortFieldNames.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
// Helper: Resolve anyOf/oneOf union types to get the primary type
|
||||
// Pydantic generates these for Optional[T] as: anyOf: [{type: T}, {type: "null"}]
|
||||
export function resolveSchemaType(schema: JSONSchemaProperty): JSONSchemaProperty {
|
||||
// If schema has a direct type, return as-is
|
||||
if (schema.type) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// Handle anyOf (common for Optional[T])
|
||||
if (schema.anyOf && schema.anyOf.length > 0) {
|
||||
// Filter out null type and get the first non-null type
|
||||
const nonNullTypes = schema.anyOf.filter(
|
||||
(s) => s.type !== "null" && s.type !== undefined
|
||||
);
|
||||
if (nonNullTypes.length > 0) {
|
||||
// Merge the resolved type with original schema's metadata (default, description, etc.)
|
||||
return {
|
||||
...nonNullTypes[0],
|
||||
default: schema.default ?? nonNullTypes[0].default,
|
||||
description: schema.description ?? nonNullTypes[0].description,
|
||||
title: schema.title ?? nonNullTypes[0].title,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle oneOf similarly
|
||||
if (schema.oneOf && schema.oneOf.length > 0) {
|
||||
const nonNullTypes = schema.oneOf.filter(
|
||||
(s) => s.type !== "null" && s.type !== undefined
|
||||
);
|
||||
if (nonNullTypes.length > 0) {
|
||||
return {
|
||||
...nonNullTypes[0],
|
||||
default: schema.default ?? nonNullTypes[0].default,
|
||||
description: schema.description ?? nonNullTypes[0].description,
|
||||
title: schema.title ?? nonNullTypes[0].title,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return original schema (will render as JSON textarea)
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function shouldFieldBeTextarea(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): boolean {
|
||||
return (
|
||||
schema.format === "textarea" ||
|
||||
(!!schema.description && schema.description.length > 100) ||
|
||||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
|
||||
);
|
||||
}
|
||||
|
||||
export function getFieldColumnSpan(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): string {
|
||||
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
|
||||
const hasLongDescription =
|
||||
!!schema.description && schema.description.length > 150;
|
||||
|
||||
if (isTextarea || hasLongDescription) {
|
||||
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
|
||||
}
|
||||
|
||||
if (
|
||||
schema.type === "array" ||
|
||||
(!!schema.description && schema.description.length > 80)
|
||||
) {
|
||||
return "xl:col-span-2";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Pattern Detection (exported for reuse)
|
||||
// ============================================================================
|
||||
|
||||
export function detectChatMessagePattern(
|
||||
schema: JSONSchemaProperty,
|
||||
requiredFields: string[]
|
||||
): boolean {
|
||||
if (schema.type !== "object" || !schema.properties) return false;
|
||||
|
||||
const properties = schema.properties;
|
||||
const optionalFields = Object.keys(properties).filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
return (
|
||||
requiredFields.includes("role") &&
|
||||
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
|
||||
properties["role"]?.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Field Component (internal - used by SchemaFormRenderer)
|
||||
// ============================================================================
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
schema: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isRequired?: boolean;
|
||||
isReadOnly?: boolean; // NEW: for HIL display-only fields
|
||||
}
|
||||
|
||||
function FormField({
|
||||
name,
|
||||
schema: rawSchema,
|
||||
value,
|
||||
onChange,
|
||||
isRequired = false,
|
||||
isReadOnly = false,
|
||||
}: FormFieldProps) {
|
||||
// Resolve anyOf/oneOf union types (e.g., Optional[int] → int)
|
||||
const schema = resolveSchemaType(rawSchema);
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
const isTextarea = shouldFieldBeTextarea(name, schema);
|
||||
|
||||
const renderInput = () => {
|
||||
// Read-only display (for HIL request context)
|
||||
if (isReadOnly) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-muted-foreground">
|
||||
{name}
|
||||
</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded border">
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
if (enumValues) {
|
||||
// Enum select
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
typeof value === "string" && value
|
||||
? value
|
||||
: typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: enumValues[0]
|
||||
}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${name}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enumValues.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (isTextarea) {
|
||||
// Multi-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={4}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Single-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "integer":
|
||||
case "number":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
step={type === "integer" ? "1" : "any"}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
type === "integer"
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
onChange(isNaN(val) ? "" : val);
|
||||
}}
|
||||
placeholder={
|
||||
typeof defaultValue === "number"
|
||||
? defaultValue.toString()
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "array":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
Array.isArray(value)
|
||||
? value.join(", ")
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const arrayValue = e.target.value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
onChange(arrayValue);
|
||||
}}
|
||||
placeholder="Enter items separated by commas"
|
||||
rows={2}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "object":
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
typeof value === "object" && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange(parsed);
|
||||
} catch {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Schema Form Renderer Component
|
||||
// ============================================================================
|
||||
|
||||
export interface SchemaFormRendererProps {
|
||||
schema: JSONSchemaProperty;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
readOnlyFields?: string[]; // Fields to display but not edit (for HIL)
|
||||
hideFields?: string[]; // Fields to completely hide
|
||||
showCollapsedByDefault?: boolean; // Control initial collapsed state
|
||||
layout?: "stack" | "grid"; // Layout mode: "stack" (vertical) or "grid" (responsive 4-col)
|
||||
}
|
||||
|
||||
export function SchemaFormRenderer({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnlyFields = [],
|
||||
hideFields = [],
|
||||
showCollapsedByDefault = false,
|
||||
layout = "stack",
|
||||
}: SchemaFormRendererProps) {
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(
|
||||
showCollapsedByDefault
|
||||
);
|
||||
|
||||
// Container class based on layout mode
|
||||
const containerClass =
|
||||
layout === "grid"
|
||||
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
: "space-y-3";
|
||||
|
||||
const properties = schema.properties || {};
|
||||
const allFieldNames = Object.keys(properties).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
const requiredFields = (schema.required || []).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
|
||||
// Detect Message pattern
|
||||
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
|
||||
|
||||
// Separate required and optional fields
|
||||
const requiredFieldNames = allFieldNames.filter(
|
||||
(name) =>
|
||||
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
|
||||
);
|
||||
|
||||
const optionalFieldNames = allFieldNames.filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
// For Message: prioritize text/message/content
|
||||
const sortedOptionalFields = isChatMessageLike
|
||||
? [...optionalFieldNames].sort((a, b) => {
|
||||
const priority = (name: string) =>
|
||||
["text", "message", "content"].includes(name) ? 1 : 0;
|
||||
return priority(b) - priority(a);
|
||||
})
|
||||
: optionalFieldNames;
|
||||
|
||||
// Show minimum visible fields
|
||||
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
|
||||
const visibleOptionalCount = Math.max(
|
||||
0,
|
||||
MIN_VISIBLE_FIELDS - requiredFieldNames.length
|
||||
);
|
||||
const visibleOptionalFields = sortedOptionalFields.slice(
|
||||
0,
|
||||
visibleOptionalCount
|
||||
);
|
||||
const collapsedOptionalFields = sortedOptionalFields.slice(
|
||||
visibleOptionalCount
|
||||
);
|
||||
|
||||
const hasCollapsedFields = collapsedOptionalFields.length > 0;
|
||||
const hasRequiredFields = requiredFieldNames.length > 0;
|
||||
|
||||
const updateField = (fieldName: string, value: unknown) => {
|
||||
onChange({
|
||||
...values,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// Full-width class for separator and toggle button in grid mode
|
||||
const fullWidthClass =
|
||||
layout === "grid" ? "md:col-span-2 lg:col-span-3 xl:col-span-4" : "";
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className={fullWidthClass}>
|
||||
<div className="border-t border-border"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className={fullWidthClass}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
disabled={disabled}
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields */}
|
||||
{showAdvancedFields &&
|
||||
collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export helper functions for validation
|
||||
// ============================================================================
|
||||
|
||||
export function validateSchemaForm(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
return requiredFields.every((fieldName) => {
|
||||
const value = values[fieldName];
|
||||
return value !== undefined && value !== "" && value !== null;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterEmptyOptionalFields(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const requiredFields = schema.required || [];
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
Object.keys(values).forEach((key) => {
|
||||
const value = values[key];
|
||||
// Include if: 1) required field, OR 2) has non-empty value
|
||||
if (
|
||||
requiredFields.includes(key) ||
|
||||
(value !== undefined && value !== "" && value !== null)
|
||||
) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { memo, type CSSProperties } from "react";
|
||||
import { BaseEdge, useInternalNode } from "@xyflow/react";
|
||||
|
||||
interface SelfLoopEdgeProps {
|
||||
id: string;
|
||||
source: string;
|
||||
markerEnd?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom edge for self-referencing nodes. Renders a bezier loop from output back to input.
|
||||
*/
|
||||
export const SelfLoopEdge = memo(function SelfLoopEdge({
|
||||
id,
|
||||
source,
|
||||
markerEnd,
|
||||
style,
|
||||
}: SelfLoopEdgeProps) {
|
||||
const sourceNode = useInternalNode(source);
|
||||
if (!sourceNode) return null;
|
||||
|
||||
const { width, height } = sourceNode.measured;
|
||||
const { x, y } = sourceNode.internals.positionAbsolute;
|
||||
if (!width || !height) return null;
|
||||
|
||||
const nodeData = sourceNode.data as Record<string, unknown> | undefined;
|
||||
const isVertical = nodeData?.layoutDirection === "TB";
|
||||
|
||||
const loopOffset = 100;
|
||||
const riseOffset = 40;
|
||||
|
||||
let edgePath: string;
|
||||
|
||||
if (isVertical) {
|
||||
// TB: bottom center → curves right → top center
|
||||
const startX = x + width / 2;
|
||||
const startY = y + height;
|
||||
const endX = x + width / 2;
|
||||
const endY = y;
|
||||
const cpX = x + width + loopOffset;
|
||||
|
||||
edgePath = `M ${startX} ${startY} C ${startX} ${startY + riseOffset}, ${cpX} ${startY + riseOffset}, ${cpX} ${y + height / 2} C ${cpX} ${endY - riseOffset}, ${endX} ${endY - riseOffset}, ${endX} ${endY}`;
|
||||
} else {
|
||||
// LR: right center → curves down → left center
|
||||
const startX = x + width;
|
||||
const startY = y + height / 2;
|
||||
const endX = x;
|
||||
const endY = y + height / 2;
|
||||
const cpY = y + height + loopOffset;
|
||||
|
||||
edgePath = `M ${startX} ${startY} C ${startX + riseOffset} ${startY}, ${startX + riseOffset} ${cpY}, ${x + width / 2} ${cpY} C ${endX - riseOffset} ${cpY}, ${endX - riseOffset} ${endY}, ${endX} ${endY}`;
|
||||
}
|
||||
|
||||
return <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />;
|
||||
});
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Workflow as WorkflowIcon,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import type { WorkflowInfo } from "@/types";
|
||||
|
||||
interface WorkflowDetailsModalProps {
|
||||
workflow: WorkflowInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: WorkflowDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
workflow.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : workflow.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
workflow.source === "directory"
|
||||
? "Local"
|
||||
: workflow.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Workflow Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<WorkflowIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{workflow.name || workflow.id}
|
||||
</h2>
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<p className="text-muted-foreground">{workflow.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Start Executor */}
|
||||
<DetailCard
|
||||
title="Start Executor"
|
||||
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="font-mono text-foreground">
|
||||
{workflow.start_executor_id}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{workflow.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{workflow.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
workflow.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
workflow.has_env
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{workflow.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Executors */}
|
||||
<DetailCard
|
||||
title={`Executors (${workflow.executors.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{workflow.executors.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{workflow.executors.map((executor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded truncate"
|
||||
title={executor}
|
||||
>
|
||||
{executor}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No executors configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import { useMemo, useCallback, useEffect, memo } from "react";
|
||||
import {
|
||||
MoreVertical,
|
||||
Map,
|
||||
Grid3X3,
|
||||
RotateCcw,
|
||||
Maximize,
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
BackgroundVariant,
|
||||
type NodeTypes,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
|
||||
import { SelfLoopEdge } from "./self-loop-edge";
|
||||
import {
|
||||
convertWorkflowDumpToNodes,
|
||||
convertWorkflowDumpToEdges,
|
||||
applyDagreLayout,
|
||||
processWorkflowEvents,
|
||||
updateNodesWithEvents,
|
||||
updateEdgesWithSequenceAnalysis,
|
||||
consolidateBidirectionalEdges,
|
||||
type NodeUpdate,
|
||||
} from "@/utils/workflow-utils";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import type { Workflow } from "@/types/workflow";
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
executor: ExecutorNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
selfLoop: SelfLoopEdge,
|
||||
};
|
||||
|
||||
// ViewOptions panel component that renders inside ReactFlow
|
||||
function ViewOptionsPanel({
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
viewOptions,
|
||||
onToggleViewOption,
|
||||
layoutDirection,
|
||||
onLayoutDirectionChange,
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: {
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
animateRun: boolean;
|
||||
consolidateBidirectionalEdges: boolean;
|
||||
};
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
}) {
|
||||
const { fitView, setViewport, setNodes } = useReactFlow();
|
||||
|
||||
const handleResetZoom = () => {
|
||||
setViewport({ x: 0, y: 0, zoom: 1 });
|
||||
};
|
||||
|
||||
const handleFitToScreen = () => {
|
||||
fitView({ padding: 0.2 });
|
||||
};
|
||||
|
||||
const handleAutoArrange = () => {
|
||||
if (!workflowDump) return;
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
layoutDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">View options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("showMinimap")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Map className="mr-2 h-4 w-4" />
|
||||
Show Minimap
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("showGrid")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Grid3X3 className="mr-2 h-4 w-4" />
|
||||
Show Grid
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("animateRun")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Animate Run
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() =>
|
||||
onToggleViewOption?.("consolidateBidirectionalEdges")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
||||
Merge Bidirectional Edges
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={viewOptions.consolidateBidirectionalEdges}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
|
||||
onLayoutDirectionChange?.(newDirection);
|
||||
// Re-apply layout with new direction
|
||||
if (workflowDump) {
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
newDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
newDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowDown className="mr-2 h-4 w-4" />
|
||||
Vertical Layout
|
||||
</div>
|
||||
<Checkbox checked={layoutDirection === "TB"} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleResetZoom}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset Zoom
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleFitToScreen}>
|
||||
<Maximize className="mr-2 h-4 w-4" />
|
||||
Fit to Screen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAutoArrange}>
|
||||
<Shuffle className="mr-2 h-4 w-4" />
|
||||
Auto-arrange
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkflowFlowProps {
|
||||
workflowDump?: Workflow;
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming: boolean;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
className?: string;
|
||||
viewOptions?: {
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
animateRun: boolean;
|
||||
consolidateBidirectionalEdges: boolean;
|
||||
};
|
||||
onToggleViewOption?: (
|
||||
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
|
||||
) => void;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
timelineVisible?: boolean;
|
||||
}
|
||||
|
||||
// Animation handler component that runs inside ReactFlow context
|
||||
function WorkflowAnimationHandler({
|
||||
nodes,
|
||||
nodeUpdates,
|
||||
isStreaming,
|
||||
animateRun,
|
||||
}: {
|
||||
nodes: Node<ExecutorNodeData>[];
|
||||
nodeUpdates: Record<string, NodeUpdate>;
|
||||
isStreaming: boolean;
|
||||
animateRun: boolean;
|
||||
}) {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Smooth animation to center on running node when workflow starts/progresses
|
||||
useEffect(() => {
|
||||
if (!animateRun) return;
|
||||
|
||||
if (isStreaming) {
|
||||
// Zoom in on running nodes during execution
|
||||
const runningNodes = nodes.filter(
|
||||
(node) => node.data.state === "running"
|
||||
);
|
||||
if (runningNodes.length > 0) {
|
||||
const targetNode = runningNodes[0];
|
||||
|
||||
// Use fitView to smoothly focus on the running node with animation
|
||||
fitView({
|
||||
nodes: [targetNode],
|
||||
duration: 800,
|
||||
padding: 0.3,
|
||||
minZoom: 0.8,
|
||||
maxZoom: 1.5,
|
||||
});
|
||||
}
|
||||
} else if (nodes.length > 0) {
|
||||
// Zoom back out to show full workflow when execution completes
|
||||
fitView({
|
||||
duration: 1000,
|
||||
padding: 0.2,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeUpdates, isStreaming, animateRun, nodes]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
// Timeline resize handler component that runs inside ReactFlow context
|
||||
const TimelineResizeHandler = memo(
|
||||
({ timelineVisible }: { timelineVisible: boolean }) => {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
|
||||
useEffect(() => {
|
||||
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 350); // Slightly longer than timeline animation duration
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
);
|
||||
|
||||
export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
onNodeSelect,
|
||||
className = "",
|
||||
viewOptions = {
|
||||
showMinimap: false,
|
||||
showGrid: true,
|
||||
animateRun: true,
|
||||
consolidateBidirectionalEdges: true,
|
||||
},
|
||||
onToggleViewOption,
|
||||
layoutDirection = "TB",
|
||||
onLayoutDirectionChange,
|
||||
timelineVisible = false,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
if (!workflowDump) {
|
||||
return { initialNodes: [], initialEdges: [] };
|
||||
}
|
||||
|
||||
const nodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply bidirectional edge consolidation if enabled
|
||||
const finalEdges = viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(edges)
|
||||
: edges;
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0
|
||||
? applyDagreLayout(nodes, finalEdges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: finalEdges,
|
||||
};
|
||||
}, [
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection,
|
||||
viewOptions.consolidateBidirectionalEdges,
|
||||
]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
// Process events and update node/edge states
|
||||
const nodeUpdates = useMemo(() => {
|
||||
return processWorkflowEvents(events, workflowDump?.start_executor_id);
|
||||
}, [events, workflowDump?.start_executor_id]);
|
||||
|
||||
// Update nodes and edges with real-time state from events
|
||||
useMemo(() => {
|
||||
if (Object.keys(nodeUpdates).length > 0) {
|
||||
setNodes((currentNodes) =>
|
||||
updateNodesWithEvents(currentNodes, nodeUpdates, isStreaming)
|
||||
);
|
||||
} else if (events.length === 0) {
|
||||
// Reset all nodes to pending state when events are cleared
|
||||
setNodes((currentNodes) =>
|
||||
currentNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
state: "pending" as const,
|
||||
outputData: undefined,
|
||||
error: undefined,
|
||||
isStreaming: false,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [nodeUpdates, setNodes, events.length, isStreaming]);
|
||||
|
||||
// Update edges with sequence-based analysis (separate from nodeUpdates)
|
||||
useMemo(() => {
|
||||
if (events.length > 0) {
|
||||
setEdges((currentEdges) => {
|
||||
const updatedEdges = updateEdgesWithSequenceAnalysis(
|
||||
currentEdges,
|
||||
events
|
||||
);
|
||||
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(updatedEdges)
|
||||
: updatedEdges;
|
||||
});
|
||||
} else {
|
||||
// Reset all edges to default state when events are cleared
|
||||
setEdges((currentEdges) => {
|
||||
const resetEdges = currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: "#6b7280", // Gray
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}));
|
||||
// Apply consolidation if enabled
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(resetEdges)
|
||||
: resetEdges;
|
||||
});
|
||||
}
|
||||
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
|
||||
|
||||
// Initialize nodes and edges when workflow structure OR consolidation setting changes
|
||||
useEffect(() => {
|
||||
if (initialNodes.length > 0) {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
|
||||
event.stopPropagation();
|
||||
onNodeSelect?.(node.data.executorId, node.data);
|
||||
},
|
||||
[onNodeSelect]
|
||||
);
|
||||
|
||||
if (!workflowDump) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
|
||||
>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-lg font-medium mb-2">No Workflow Data</div>
|
||||
<div className="text-sm">Workflow dump is not available.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initialNodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
|
||||
>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-lg font-medium mb-2">No Executors Found</div>
|
||||
<div className="text-sm">
|
||||
Could not extract executors from workflow dump.
|
||||
</div>
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer">Debug Info</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
|
||||
{JSON.stringify(workflowDump, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full ${className}`}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.5}
|
||||
defaultEdgeOptions={{
|
||||
type: "default",
|
||||
animated: false,
|
||||
style: { stroke: "#6b7280", strokeWidth: 2 },
|
||||
}}
|
||||
nodesDraggable={!isStreaming} // Disable dragging during execution
|
||||
nodesConnectable={false} // Disable connecting nodes
|
||||
elementsSelectable={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
{viewOptions.showGrid && (
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#e5e7eb"
|
||||
className="dark:opacity-30"
|
||||
/>
|
||||
)}
|
||||
<Controls
|
||||
position="bottom-left"
|
||||
showInteractive={false}
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "3px",
|
||||
}}
|
||||
className="dark:!bg-gray-800/90 dark:!border-gray-600"
|
||||
/>
|
||||
{viewOptions.showMinimap && (
|
||||
<MiniMap
|
||||
nodeColor={(node: Node) => {
|
||||
const data = node.data as ExecutorNodeData;
|
||||
const state = data?.state;
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "#643FB2";
|
||||
case "completed":
|
||||
return "#10b981";
|
||||
case "failed":
|
||||
return "#ef4444";
|
||||
case "cancelled":
|
||||
return "#f97316";
|
||||
default:
|
||||
return "#6b7280";
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
position="bottom-right"
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
className="dark:!bg-gray-800/90 dark:!border-gray-600"
|
||||
/>
|
||||
)}
|
||||
<WorkflowAnimationHandler
|
||||
nodes={nodes}
|
||||
nodeUpdates={nodeUpdates}
|
||||
isStreaming={isStreaming}
|
||||
animateRun={viewOptions.animateRun}
|
||||
/>
|
||||
<TimelineResizeHandler timelineVisible={timelineVisible} />
|
||||
<ViewOptionsPanel
|
||||
workflowDump={workflowDump}
|
||||
onNodeSelect={onNodeSelect}
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={onToggleViewOption}
|
||||
layoutDirection={layoutDirection}
|
||||
onLayoutDirectionChange={onLayoutDirectionChange}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* CSS for custom edge animations and dark theme controls */}
|
||||
<style>{`
|
||||
.react-flow__edge-path {
|
||||
transition: stroke 0.3s ease, stroke-width 0.3s ease;
|
||||
}
|
||||
.react-flow__edge.animated .react-flow__edge-path {
|
||||
stroke-dasharray: 5 5;
|
||||
animation: dash 1s linear infinite;
|
||||
}
|
||||
@keyframes dash {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: -10; }
|
||||
}
|
||||
|
||||
/* Dark theme styles for React Flow controls */
|
||||
.dark .react-flow__controls {
|
||||
background-color: rgba(31, 41, 55, 0.9) !important;
|
||||
border-color: rgb(75, 85, 99) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button {
|
||||
background-color: rgba(31, 41, 55, 0.9) !important;
|
||||
border-color: rgb(75, 85, 99) !important;
|
||||
color: rgb(229, 231, 235) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button:hover {
|
||||
background-color: rgba(55, 65, 81, 0.9) !important;
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button svg {
|
||||
fill: rgb(229, 231, 235) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button:hover svg {
|
||||
fill: rgb(255, 255, 255) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Send } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
import {
|
||||
SchemaFormRenderer,
|
||||
filterEmptyOptionalFields,
|
||||
detectChatMessagePattern,
|
||||
} from "./schema-form-renderer";
|
||||
|
||||
interface WorkflowInputFormProps {
|
||||
inputSchema: JSONSchemaProperty;
|
||||
inputTypeName: string;
|
||||
onSubmit: (formData: unknown) => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkflowInputForm({
|
||||
inputSchema,
|
||||
inputTypeName,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
className,
|
||||
}: WorkflowInputFormProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Check if we're in embedded mode (being used inside another modal)
|
||||
const isEmbedded = className?.includes("embedded");
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Determine field info
|
||||
const properties = inputSchema.properties || {};
|
||||
const fieldNames = Object.keys(properties);
|
||||
const requiredFields = inputSchema.required || [];
|
||||
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
|
||||
|
||||
// Detect Message-like pattern for auto-filling role
|
||||
const isChatMessageLike = detectChatMessagePattern(inputSchema, requiredFields);
|
||||
|
||||
// Validation: check if required fields are filled
|
||||
const canSubmit = isSimpleInput
|
||||
? formData.value !== undefined && formData.value !== ""
|
||||
: requiredFields.length > 0
|
||||
? requiredFields.every((fieldName) => {
|
||||
// Auto-filled fields are always valid
|
||||
if (
|
||||
isChatMessageLike &&
|
||||
fieldName === "role" &&
|
||||
formData["role"] === "user"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return formData[fieldName] !== undefined && formData[fieldName] !== "";
|
||||
})
|
||||
: Object.keys(formData).length > 0;
|
||||
|
||||
// Initialize form data with defaults
|
||||
useEffect(() => {
|
||||
if (inputSchema.type === "string") {
|
||||
setFormData({ value: inputSchema.default || "" });
|
||||
} else if (inputSchema.type === "object" && inputSchema.properties) {
|
||||
const initialData: Record<string, unknown> = {};
|
||||
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
|
||||
if (fieldSchema.default !== undefined) {
|
||||
initialData[key] = fieldSchema.default;
|
||||
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
|
||||
initialData[key] = fieldSchema.enum[0];
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-fill role="user" for Message-like inputs
|
||||
if (isChatMessageLike && !initialData["role"]) {
|
||||
initialData["role"] = "user";
|
||||
}
|
||||
|
||||
setFormData(initialData);
|
||||
}
|
||||
}, [inputSchema, isChatMessageLike]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
// Simplified submission logic
|
||||
if (inputSchema.type === "string") {
|
||||
onSubmit({ input: formData.value || "" });
|
||||
} else if (inputSchema.type === "object") {
|
||||
const properties = inputSchema.properties || {};
|
||||
const fieldNames = Object.keys(properties);
|
||||
|
||||
if (fieldNames.length === 1) {
|
||||
const fieldName = fieldNames[0];
|
||||
onSubmit({ [fieldName]: formData[fieldName] || "" });
|
||||
} else {
|
||||
// Filter out empty optional fields before submission
|
||||
const filteredData = filterEmptyOptionalFields(inputSchema, formData);
|
||||
onSubmit(filteredData);
|
||||
}
|
||||
} else {
|
||||
onSubmit(formData);
|
||||
}
|
||||
|
||||
// Only close modal if not embedded
|
||||
if (!isEmbedded) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleFormChange = (newValues: Record<string, unknown>) => {
|
||||
setFormData(newValues);
|
||||
};
|
||||
|
||||
// Simple string input renderer (for non-object schemas)
|
||||
const renderSimpleInput = () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="simple-input">Input</Label>
|
||||
<Textarea
|
||||
id="simple-input"
|
||||
value={typeof formData.value === "string" ? formData.value : ""}
|
||||
onChange={(e) => setFormData({ value: e.target.value })}
|
||||
placeholder={
|
||||
typeof inputSchema.default === "string"
|
||||
? inputSchema.default
|
||||
: "Enter input"
|
||||
}
|
||||
rows={4}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{inputSchema.description && (
|
||||
<p className="text-sm text-muted-foreground">{inputSchema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// If embedded, just show the form directly
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && renderSimpleInput()}
|
||||
|
||||
{/* Complex form fields using SchemaFormRenderer */}
|
||||
{!isSimpleInput && (
|
||||
<SchemaFormRenderer
|
||||
schema={inputSchema}
|
||||
values={formData}
|
||||
onChange={handleFormChange}
|
||||
disabled={loading}
|
||||
hideFields={isChatMessageLike ? ["role"] : []}
|
||||
layout="grid"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 justify-end">
|
||||
<Button type="submit" disabled={loading || !canSubmit} size="default">
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar Form Component */}
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
{/* Header with Run Button */}
|
||||
<div className="border-b border-border px-4 py-3 bg-muted">
|
||||
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
|
||||
|
||||
{/* Run Button - Opens Modal */}
|
||||
<Button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full"
|
||||
size="default"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{isSubmitting ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<strong>Input Type:</strong>{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
{inputTypeName}
|
||||
</code>
|
||||
{inputSchema.type === "object" && inputSchema.properties && (
|
||||
<span className="ml-2">
|
||||
({Object.keys(inputSchema.properties).length} field
|
||||
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click "Run Workflow" to configure inputs and execute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal with the actual form */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Workflow</DialogTitle>
|
||||
<DialogClose onClose={() => setIsModalOpen(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
{/* Form Info */}
|
||||
<div className="px-8 py-4 border-b flex-shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">Input Type:</span>
|
||||
<code className="bg-muted px-3 py-1 text-xs font-mono">
|
||||
{inputTypeName}
|
||||
</code>
|
||||
{inputSchema.type === "object" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fieldNames.length} field
|
||||
{fieldNames.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
|
||||
<form id="workflow-modal-form" onSubmit={handleSubmit}>
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && renderSimpleInput()}
|
||||
|
||||
{/* Complex form fields using SchemaFormRenderer */}
|
||||
{!isSimpleInput && (
|
||||
<SchemaFormRenderer
|
||||
schema={inputSchema}
|
||||
values={formData}
|
||||
onChange={handleFormChange}
|
||||
disabled={loading}
|
||||
hideFields={isChatMessageLike ? ["role"] : []}
|
||||
layout="grid"
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-8 py-4 border-t flex-shrink-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="workflow-modal-form"
|
||||
disabled={loading || !canSubmit}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{loading ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Workflow Conversation Manager Component
|
||||
* Handles conversation selection, creation, and deletion for workflow executions
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useDevUIStore } from "@/stores/devuiStore";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { Trash2, Plus, Clock } from "lucide-react";
|
||||
import type { WorkflowSession } from "@/types";
|
||||
|
||||
interface WorkflowSessionManagerProps {
|
||||
workflowId: string;
|
||||
onSessionChange?: (session: WorkflowSession | undefined) => void;
|
||||
}
|
||||
|
||||
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
|
||||
workflowId,
|
||||
onSessionChange,
|
||||
}) => {
|
||||
// Use individual selectors to avoid creating new objects on every render
|
||||
const currentSession = useDevUIStore((state) => state.currentSession);
|
||||
const availableSessions = useDevUIStore((state) => state.availableSessions);
|
||||
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
|
||||
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
|
||||
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
|
||||
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
|
||||
const addSession = useDevUIStore((state) => state.addSession);
|
||||
const removeSession = useDevUIStore((state) => state.removeSession);
|
||||
const addToast = useDevUIStore((state) => state.addToast);
|
||||
const runtime = useDevUIStore((state) => state.runtime);
|
||||
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
const response = await apiClient.listWorkflowSessions(workflowId);
|
||||
|
||||
// If no conversations exist, auto-create one (like agent conversations)
|
||||
if (response.data.length === 0) {
|
||||
console.log("No workflow conversations found, creating default conversation");
|
||||
const newSession = await apiClient.createWorkflowSession(workflowId, {
|
||||
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
|
||||
});
|
||||
setAvailableSessions([newSession]);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "Default checkpoint storage created",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
// Conversations exist - set available and auto-select the first one
|
||||
setAvailableSessions(response.data);
|
||||
|
||||
// Auto-select first conversation if no current selection
|
||||
if (!currentSession) {
|
||||
const firstSession = response.data[0];
|
||||
setCurrentSession(firstSession);
|
||||
onSessionChange?.(firstSession);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workflow conversations:", error);
|
||||
|
||||
// Silently handle for .NET backend (doesn't support conversations yet)
|
||||
// Only show error for Python backend where this is unexpected
|
||||
if (runtime !== "dotnet") {
|
||||
addToast({
|
||||
message: "Failed to load workflow conversations",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}, [workflowId, currentSession, runtime, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setCreatingSession(true);
|
||||
try {
|
||||
const newSession = await apiClient.createWorkflowSession(workflowId, {
|
||||
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
|
||||
});
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "New checkpoint storage created",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint storage:", error);
|
||||
addToast({
|
||||
message: "Failed to create checkpoint storage",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (session: WorkflowSession) => {
|
||||
setCurrentSession(session);
|
||||
onSessionChange?.(session);
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (
|
||||
sessionId: string,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
event.stopPropagation(); // Prevent session selection when clicking delete
|
||||
|
||||
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingSession(sessionId);
|
||||
try {
|
||||
await apiClient.deleteWorkflowSession(workflowId, sessionId);
|
||||
removeSession(sessionId);
|
||||
addToast({
|
||||
message: "Conversation deleted",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete conversation:", error);
|
||||
addToast({
|
||||
message: "Failed to delete conversation",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setDeletingSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (loadingSessions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-session-manager space-y-3">
|
||||
{/* Header with Create Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Conversations
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
disabled={creatingSession}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Create new conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
{availableSessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableSessions.map((session) => (
|
||||
<div
|
||||
key={session.conversation_id}
|
||||
onClick={() => handleSelectSession(session)}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
|
||||
${
|
||||
currentSession?.conversation_id === session.conversation_id
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{session.metadata.name || "Unnamed Conversation"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTimestamp(session.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
|
||||
disabled={deletingSession === session.conversation_id}
|
||||
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
|
||||
title="Delete conversation"
|
||||
>
|
||||
{deletingSession === session.conversation_id ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* AppHeader - Global application header
|
||||
* Features: Entity selection, global settings, theme toggle
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EntitySelector } from "./entity-selector";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Settings, Zap } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
interface AppHeaderProps {
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities?: (AgentInfo | WorkflowInfo)[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
onSettingsClick,
|
||||
}: AppHeaderProps) {
|
||||
const { oaiMode, serverVersion } = useDevUIStore();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 805 805"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z"
|
||||
fill="url(#paint0_linear_510_1294)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_510_1294"
|
||||
x1="255.628"
|
||||
y1="-34.3245"
|
||||
x2="618.483"
|
||||
y2="632.032"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#D59FFF" />
|
||||
<stop offset="1" stopColor="#8562C5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
Dev UI
|
||||
{serverVersion && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
v{serverVersion}
|
||||
</span>
|
||||
)}
|
||||
{/* Mode Badge */}
|
||||
{oaiMode.enabled && (
|
||||
<Badge variant="secondary" className="gap-1 ml-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
OpenAI: {oaiMode.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show entity selector only when NOT in OAI mode */}
|
||||
{!oaiMode.enabled && (
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ModeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSettingsClick?.();
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* DeploymentModal - Shows Azure deployment instructions and Docker templates
|
||||
* Features: Docker setup files, Azure Container Apps deployment guide
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Rocket,
|
||||
Container,
|
||||
Cloud,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface DeploymentModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agentName?: string;
|
||||
entity?: AgentInfo | WorkflowInfo;
|
||||
}
|
||||
|
||||
type Tab = "docker" | "azure";
|
||||
|
||||
export function DeploymentModal({
|
||||
open,
|
||||
onClose,
|
||||
agentName = "Agent",
|
||||
entity,
|
||||
}: DeploymentModalProps) {
|
||||
// Get the Azure deployment feature flag from store
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
|
||||
// Check if deployment is truly supported (both feature flag and backend support)
|
||||
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
|
||||
|
||||
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
|
||||
const [activeTab, setActiveTab] = useState<Tab>(
|
||||
deploymentSupported ? "azure" : "docker"
|
||||
);
|
||||
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Deployment state from Zustand
|
||||
const isDeploying = useDevUIStore((state) => state.isDeploying);
|
||||
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
|
||||
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
|
||||
const startDeployment = useDevUIStore((state) => state.startDeployment);
|
||||
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
|
||||
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
|
||||
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
|
||||
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
|
||||
|
||||
// Generate Azure-compliant default app name from entity name
|
||||
const generateDefaultAppName = (entityName: string) => {
|
||||
// Convert to lowercase, replace spaces and underscores with hyphens
|
||||
// Remove any non-alphanumeric characters except hyphens
|
||||
// Ensure it starts with a letter and is under 32 chars
|
||||
const cleaned = entityName
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
|
||||
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
|
||||
.replace(/--+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
|
||||
.replace(/-$/, ''); // Remove trailing hyphen
|
||||
|
||||
// Ensure it starts with a letter, add 'app-' prefix if needed
|
||||
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
|
||||
|
||||
// Truncate to 31 chars max (32 limit)
|
||||
return withPrefix.substring(0, 31);
|
||||
};
|
||||
|
||||
// Form state for deployment with smart defaults
|
||||
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
|
||||
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
|
||||
const [appName, setAppName] = useState(defaultAppName);
|
||||
const [region, setRegion] = useState("eastus");
|
||||
const [appNameError, setAppNameError] = useState<string | null>(null);
|
||||
|
||||
// Update app name when entity changes or modal opens
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
const newDefaultName = generateDefaultAppName(entity.id);
|
||||
setAppName(newDefaultName);
|
||||
// Validate the default name
|
||||
const error = validateAppName(newDefaultName);
|
||||
setAppNameError(error);
|
||||
}
|
||||
}, [entity?.id]); // Only re-run when entity ID changes
|
||||
|
||||
// Auto-scroll deployment logs to bottom when new logs are added
|
||||
useEffect(() => {
|
||||
if (logsContainerRef.current && deploymentLogs.length > 0) {
|
||||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [deploymentLogs]);
|
||||
|
||||
// Validate Azure Container App name
|
||||
const validateAppName = (name: string): string | null => {
|
||||
if (!name) return null; // Don't show error for empty field
|
||||
|
||||
// Check length
|
||||
if (name.length >= 32) {
|
||||
return "App name must be less than 32 characters";
|
||||
}
|
||||
|
||||
// Check for valid characters (lowercase alphanumeric and hyphens only)
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
|
||||
}
|
||||
|
||||
// Must start with a letter
|
||||
if (!/^[a-z]/.test(name)) {
|
||||
return "App name must start with a lowercase letter";
|
||||
}
|
||||
|
||||
// Must end with alphanumeric
|
||||
if (!/[a-z0-9]$/.test(name)) {
|
||||
return "App name must end with a letter or number";
|
||||
}
|
||||
|
||||
// Cannot have double hyphens
|
||||
if (name.includes("--")) {
|
||||
return "App name cannot contain consecutive hyphens (--)";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!entity?.id || !resourceGroup || !appName) return;
|
||||
|
||||
// Trim whitespace from inputs
|
||||
const trimmedResourceGroup = resourceGroup.trim();
|
||||
const trimmedAppName = appName.trim();
|
||||
|
||||
// Validate trimmed app name before deployment
|
||||
const nameError = validateAppName(trimmedAppName);
|
||||
if (nameError) {
|
||||
setAppNameError(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startDeployment();
|
||||
|
||||
for await (const event of apiClient.streamDeployment({
|
||||
entity_id: entity.id,
|
||||
resource_group: trimmedResourceGroup,
|
||||
app_name: trimmedAppName,
|
||||
region,
|
||||
ui_mode: "user",
|
||||
})) {
|
||||
addDeploymentLog(event.message);
|
||||
|
||||
if (event.type === "deploy.completed" && event.url && event.auth_token) {
|
||||
setDeploymentResult({
|
||||
url: event.url,
|
||||
authToken: event.auth_token,
|
||||
});
|
||||
} else if (event.type === "deploy.failed") {
|
||||
// Stop deploying but keep logs visible
|
||||
stopDeployment();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
|
||||
stopDeployment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (template: string, templateName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(template);
|
||||
setCopiedTemplate(templateName);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout with cleanup
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopiedTemplate(null);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Reset state on error - clipboard write failed
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dockerfileTemplate = `# Dockerfile for ${agentName}
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy agent/workflow directories
|
||||
COPY . .
|
||||
|
||||
# Expose DevUI default port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run DevUI server
|
||||
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
|
||||
`;
|
||||
|
||||
const dockerComposeTemplate = `# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
${agentName.toLowerCase().replace(/\s+/g, "-")}:
|
||||
build: .
|
||||
environment:
|
||||
# OpenAI
|
||||
- OPENAI_API_KEY=\${OPENAI_API_KEY}
|
||||
- OPENAI_CHAT_COMPLETION_MODEL=\${OPENAI_CHAT_COMPLETION_MODEL:-gpt-4o-mini}
|
||||
# Or Azure OpenAI
|
||||
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
|
||||
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
|
||||
- AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL}
|
||||
# Optional: Enable instrumentation
|
||||
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
`;
|
||||
|
||||
const requirementsTemplate = `# requirements.txt
|
||||
agent-framework-devui>=0.1.0
|
||||
agent-framework>=0.1.0
|
||||
# Chat clients (install what you need)
|
||||
openai>=1.0.0
|
||||
# azure-openai
|
||||
# anthropic
|
||||
`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="w-[800px] max-w-[90vw]">
|
||||
<DialogClose onClose={onClose} />
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
Deploy {agentName}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground pt-1">
|
||||
Get started with containerizing your agent for deployment.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("docker")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${activeTab === "docker"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Container className="h-4 w-4 mr-2 inline" />
|
||||
Docker
|
||||
{activeTab === "docker" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
{deploymentSupported && (
|
||||
<button
|
||||
onClick={() => setActiveTab("azure")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${activeTab === "azure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 inline" />
|
||||
Azure
|
||||
{activeTab === "azure" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[400px]">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="pr-4">
|
||||
{activeTab === "docker" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">
|
||||
Containerize with Docker
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Package your agent as a Docker container for consistent
|
||||
deployment anywhere.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dockerfile */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Dockerfile</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(dockerfileTemplate, "dockerfile")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "dockerfile" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{dockerfileTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* docker-compose.yml */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
docker-compose.yml
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(dockerComposeTemplate, "compose")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "compose" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{dockerComposeTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* requirements.txt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
requirements.txt
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(requirementsTemplate, "requirements")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "requirements" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{requirementsTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Quick Start</h4>
|
||||
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
|
||||
<li>Save the files above to your project directory</li>
|
||||
<li>
|
||||
Build:{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
docker build -t {agentName.toLowerCase()}-agent .
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Run:{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
docker-compose up
|
||||
</code>
|
||||
</li>
|
||||
<li>Your agent is now running in a container!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Production Warnings */}
|
||||
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
|
||||
⚠️ Production Considerations
|
||||
</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
|
||||
<li>
|
||||
<strong>In-memory state:</strong> Conversations are lost
|
||||
when container restarts
|
||||
</li>
|
||||
<li>
|
||||
<strong>No authentication:</strong> Add reverse proxy
|
||||
(nginx, Caddy) with auth for production
|
||||
</li>
|
||||
<li>
|
||||
<strong>Security:</strong> Use Azure Key Vault for
|
||||
secrets management
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scaling:</strong> Single instance only due to
|
||||
in-memory conversation store
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Deployment Checklist */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold text-sm mb-3">
|
||||
Pre-Deployment Checklist
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Set environment variables (API keys, secrets)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Test agent locally in container
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Configure logging and monitoring
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Set up error handling and retries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "azure" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">
|
||||
Deploy to Azure Container Apps
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{deploymentSupported
|
||||
? "One-click deployment to Azure with automatic containerization and authentication."
|
||||
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prerequisites Notice */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Prerequisites for Azure Deployment
|
||||
</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
|
||||
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
|
||||
<li>Docker installed and running</li>
|
||||
<li>Azure subscription with the following providers registered:
|
||||
<ul className="ml-4 mt-1 space-y-0.5">
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
|
||||
How to register providers?
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
|
||||
<p className="mb-1">Run these commands once per subscription:</p>
|
||||
<code className="block font-mono">
|
||||
az provider register -n Microsoft.App --wait<br />
|
||||
az provider register -n Microsoft.ContainerRegistry --wait<br />
|
||||
az provider register -n Microsoft.OperationalInsights --wait
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Functional Deployment Form (only if supported) */}
|
||||
{deploymentSupported && entity && !lastDeployment && (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{!isDeploying ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Resource Group</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
|
||||
placeholder="my-test-rg"
|
||||
value={resourceGroup}
|
||||
onChange={(e) => setResourceGroup(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">App Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${appNameError ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="my-agent-app"
|
||||
value={appName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setAppName(newName);
|
||||
// Validate on change to provide immediate feedback
|
||||
// Trim for validation to match what will be sent
|
||||
const error = validateAppName(newName.trim());
|
||||
setAppNameError(error);
|
||||
}}
|
||||
/>
|
||||
{appNameError && (
|
||||
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Region</label>
|
||||
<select
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
>
|
||||
<option value="eastus">East US</option>
|
||||
<option value="westus">West US</option>
|
||||
<option value="westeurope">West Europe</option>
|
||||
<option value="eastasia">East Asia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={!resourceGroup || !appName || !!appNameError}
|
||||
className="w-full"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
Deploy to Azure
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deploying...
|
||||
</div>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
|
||||
>
|
||||
{deploymentLogs.map((log, i) => (
|
||||
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show logs after deployment stops (success or failure) */}
|
||||
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Deployment Failed
|
||||
</div>
|
||||
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
|
||||
{deploymentLogs.map((log, i) => (
|
||||
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Screen */}
|
||||
{lastDeployment && (
|
||||
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<h4 className="font-semibold text-green-900 dark:text-green-100">
|
||||
Deployment Successful!
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-green-800 dark:text-green-200">
|
||||
Deployment URL
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
|
||||
{lastDeployment.url}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(lastDeployment.url, "_blank")}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-green-800 dark:text-green-200">
|
||||
Auth Token (save this - shown only once)
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
|
||||
{lastDeployment.authToken}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
|
||||
Deploy Another
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment Not Supported Warning */}
|
||||
{!deploymentSupported && entity?.deployment_reason && (
|
||||
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Deployment not available:</strong> {entity.deployment_reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Instructions (only show when deployment not supported) */}
|
||||
{!deploymentSupported && (
|
||||
<>
|
||||
{/* Prerequisites */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-medium text-sm">Prerequisites</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
|
||||
<li>Azure subscription</li>
|
||||
<li>
|
||||
Azure CLI installed (
|
||||
<code className="bg-muted px-1 rounded">
|
||||
az --version
|
||||
</code>
|
||||
)
|
||||
</li>
|
||||
<li>Docker installed and running</li>
|
||||
<li>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Step 1 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Create Azure Container Registry
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`# Create resource group
|
||||
az group create --name myResourceGroup --location eastus
|
||||
|
||||
# Create container registry
|
||||
az acr create --resource-group myResourceGroup \\
|
||||
--name myregistry --sku Basic`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
2
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Build and Push Docker Image
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`# Build and push in one command
|
||||
az acr build --registry myregistry \\
|
||||
--image ${agentName.toLowerCase()}-agent:latest .`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
3
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Create Container Apps Environment
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp env create --name myEnvironment \\
|
||||
--resource-group myResourceGroup \\
|
||||
--location eastus`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 4 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
4
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Deploy Container App
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
|
||||
--resource-group myResourceGroup \\
|
||||
--environment myEnvironment \\
|
||||
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
|
||||
--target-port 8080 \\
|
||||
--ingress 'external' \\
|
||||
--registry-server myregistry.azurecr.io \\
|
||||
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 5 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
5
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Get Application URL
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
|
||||
--resource-group myResourceGroup \\
|
||||
--query properties.configuration.ingress.fqdn`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Learn More</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Explore Azure Container Apps documentation for advanced
|
||||
features like scaling, monitoring, and CI/CD integration.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/container-apps/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View Azure Container Apps Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* EntitySelector - Dropdown for selecting agents/workflows
|
||||
* Features: Loading states, descriptions, lazy loading indicators
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface EntitySelectorProps {
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
return type === "workflow" ? Workflow : Bot;
|
||||
};
|
||||
|
||||
export function EntitySelector({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
}: EntitySelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
|
||||
const allItems = entities || [...agents, ...workflows];
|
||||
|
||||
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
|
||||
onSelect(item);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
|
||||
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
|
||||
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-64 justify-between font-mono text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<TypeIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{displayName}</span>
|
||||
{selectedItem && !isLoaded && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-80 font-mono">
|
||||
{/* Show items in backend order but with type grouping for clarity */}
|
||||
{(() => {
|
||||
// Group items by type while preserving order within each group
|
||||
const workflowItems = allItems.filter(item => item.type === "workflow");
|
||||
const agentItems = allItems.filter(item => item.type === "agent");
|
||||
|
||||
// Determine which type appears first in backend order
|
||||
const firstItemType = allItems[0]?.type;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Show workflows first if they appear first, otherwise agents */}
|
||||
{firstItemType === "workflow" && workflowItems.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows ({workflowItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflowItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Separator if both types exist */}
|
||||
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Agents section */}
|
||||
{agentItems.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents ({agentItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{agentItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show workflows last if agents appear first */}
|
||||
{firstItemType === "agent" && workflowItems.length > 0 && (
|
||||
<>
|
||||
{agentItems.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows ({workflowItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflowItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{allItems.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
<div className="text-center text-muted-foreground py-2">
|
||||
{isLoading ? "Loading agents and workflows..." : "No agents or workflows found"}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Browse Gallery option */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-primary"
|
||||
onClick={() => {
|
||||
onBrowseGallery?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Browse Gallery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Layout Components - Exports
|
||||
*/
|
||||
|
||||
export { AppHeader } from "./app-header";
|
||||
export { EntitySelector } from "./entity-selector";
|
||||
export { DebugPanel } from "./debug-panel";
|
||||
export { SettingsModal } from "./settings-modal";
|
||||
export { DeploymentModal } from "./deployment-modal";
|
||||
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Settings Modal - Tabbed settings dialog with About and Settings tabs
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "general" | "proxy" | "about";
|
||||
|
||||
// Preset OpenAI models for quick selection
|
||||
const PRESET_MODELS = [
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3-mini",
|
||||
] as const;
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBackendUrlChange,
|
||||
}: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, streaming, and version from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode, streamingEnabled, setStreamingEnabled } = useDevUIStore();
|
||||
|
||||
// Get current backend URL from localStorage or default
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
|
||||
const [backendUrl, setBackendUrl] = useState(() => {
|
||||
return localStorage.getItem("devui_backend_url") || defaultUrl;
|
||||
});
|
||||
const [tempUrl, setTempUrl] = useState(backendUrl);
|
||||
|
||||
// Auth token state
|
||||
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
|
||||
const [newAuthToken, setNewAuthToken] = useState("");
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(tempUrl);
|
||||
localStorage.setItem("devui_backend_url", tempUrl);
|
||||
setBackendUrl(tempUrl);
|
||||
onBackendUrlChange?.(tempUrl);
|
||||
onOpenChange(false);
|
||||
|
||||
// Reload to apply new backend URL
|
||||
window.location.reload();
|
||||
} catch {
|
||||
alert("Please enter a valid URL (e.g., http://localhost:8080)");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem("devui_backend_url");
|
||||
setTempUrl(defaultUrl);
|
||||
setBackendUrl(defaultUrl);
|
||||
onBackendUrlChange?.(defaultUrl);
|
||||
|
||||
// Reload to apply default backend URL
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleAuthTokenSave = () => {
|
||||
if (!newAuthToken.trim()) return;
|
||||
|
||||
localStorage.setItem("devui_auth_token", newAuthToken.trim());
|
||||
setAuthTokenStored(true);
|
||||
setNewAuthToken("");
|
||||
|
||||
// Reload to apply the auth token
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleClearAuthToken = () => {
|
||||
localStorage.removeItem("devui_auth_token");
|
||||
setAuthTokenStored(false);
|
||||
setNewAuthToken("");
|
||||
|
||||
// Reload to clear auth state
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const isModified = tempUrl !== backendUrl;
|
||||
const isDefault = !localStorage.getItem("devui_backend_url");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]">
|
||||
<DialogHeader className="p-6 pb-2 flex-shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "general"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
{activeTab === "general" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
{serverCapabilities.openai_proxy && (
|
||||
<button
|
||||
onClick={() => setActiveTab("proxy")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "proxy"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
OpenAI Proxy
|
||||
{activeTab === "proxy" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab("about")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "about"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
About
|
||||
{activeTab === "about" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Scrollable with min-height */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
|
||||
{activeTab === "general" && (
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Backend URL Setting */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="backend-url" className="text-sm font-medium">
|
||||
Backend URL
|
||||
</Label>
|
||||
{!isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-7 text-xs"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="backend-url"
|
||||
type="url"
|
||||
value={tempUrl}
|
||||
onChange={(e) => setTempUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: <span className="font-mono">{defaultUrl}</span>
|
||||
</p>
|
||||
|
||||
{/* Reserve space for buttons to prevent layout shift */}
|
||||
<div className="flex gap-2 pt-2 min-h-[36px]">
|
||||
{isModified && (
|
||||
<>
|
||||
<Button onClick={handleSave} size="sm" className="flex-1">
|
||||
Apply & Reload
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTempUrl(backendUrl)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
|
||||
{(authRequired || authTokenStored) && (
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
</Label>
|
||||
{!authRequired && authTokenStored && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(Not required by current backend)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authTokenStored ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value="••••••••••••••••••••"
|
||||
disabled
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAuthToken}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
✓ Token configured and stored locally
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
value={newAuthToken}
|
||||
onChange={(e) => setNewAuthToken(e.target.value)}
|
||||
placeholder="Enter bearer token"
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newAuthToken.trim()) {
|
||||
handleAuthTokenSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSave}
|
||||
size="sm"
|
||||
disabled={!newAuthToken.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Save & Reload
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authRequired
|
||||
? "Required by backend (started with --auth flag)"
|
||||
: "Not required by current backend"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment Setting - Only show if backend supports deployment */}
|
||||
{serverCapabilities.deployment && (
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Azure Deployment
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable one-click deployment to Azure Container Apps
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={azureDeploymentEnabled}
|
||||
onCheckedChange={setAzureDeploymentEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expandable info section */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Learn more about Azure deployment
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 pl-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, agents that support deployment will show a "Deploy to Azure"
|
||||
button. This allows you to deploy your agent to Azure Container Apps directly
|
||||
from DevUI.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When enabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deploy to Azure" for supported agents</li>
|
||||
<li>Requires Azure CLI and proper authentication</li>
|
||||
<li>Backend must have deployment capabilities enabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When disabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deployment Guide" for all agents</li>
|
||||
<li>Provides Docker templates and manual deployment instructions</li>
|
||||
<li>No backend deployment capabilities required</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Settings */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Show Tool Calls
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display function/tool calls and results in chat messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useDevUIStore.getState().showToolCalls}
|
||||
onCheckedChange={(checked) => useDevUIStore.getState().setShowToolCalls(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streaming Mode Setting */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Streaming Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stream responses token-by-token as they're generated
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={streamingEnabled}
|
||||
onCheckedChange={setStreamingEnabled}
|
||||
/>
|
||||
</div>
|
||||
{!streamingEnabled && (
|
||||
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Non-streaming mode limitations:</p>
|
||||
<ul className="mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80">
|
||||
<li>Tool calls won't display in real-time</li>
|
||||
<li>No typing indicator during generation</li>
|
||||
<li>Response appears all at once when complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "proxy" && serverCapabilities.openai_proxy && (
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">
|
||||
OpenAI Proxy Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Route requests through DevUI backend to OpenAI API
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oaiMode.enabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setOAIMode({ ...oaiMode, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box when disabled - prominent */}
|
||||
{!oaiMode.enabled && (
|
||||
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
About OpenAI Proxy Mode
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, your chat requests are sent to your
|
||||
DevUI backend{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
({backendUrl})
|
||||
</span>
|
||||
, which then forwards them to OpenAI's API. This keeps
|
||||
your{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
secure on the server instead of exposing it in the
|
||||
browser.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Requirements:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>
|
||||
Backend must have{" "}
|
||||
<span className="font-mono">OPENAI_API_KEY</span>{" "}
|
||||
configured
|
||||
</li>
|
||||
<li>
|
||||
Backend must support OpenAI Responses API proxying
|
||||
(DevUI does)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Why use this?</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quickly test and compare OpenAI models directly
|
||||
through the DevUI interface without creating custom
|
||||
agents or exposing API keys in the browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oaiMode.enabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Model ID Input - Primary control */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={oaiMode.model}
|
||||
onChange={(e) =>
|
||||
setOAIMode({ ...oaiMode, model: e.target.value })
|
||||
}
|
||||
placeholder="gpt-4.1-mini"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Preset Buttons */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Common presets
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_MODELS.map((model) => (
|
||||
<Button
|
||||
key={model}
|
||||
variant={
|
||||
oaiMode.model === model ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setOAIMode({ ...oaiMode, model })}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{model}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Advanced Parameters (optional)
|
||||
</summary>
|
||||
<div className="space-y-3 mt-3 pl-4">
|
||||
{/* Temperature */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Temperature</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={oaiMode.temperature ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
temperature: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls randomness (0-2)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Max Output Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={oaiMode.max_output_tokens ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
max_output_tokens: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Auto"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum tokens in response
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Top P</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={oaiMode.top_p ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
top_p: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nucleus sampling (0-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reasoning Effort */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
|
||||
<select
|
||||
value={oaiMode.reasoning_effort ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
reasoning_effort: e.target.value
|
||||
? (e.target.value as "minimal" | "low" | "medium" | "high")
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Constrains reasoning effort (faster/cheaper vs thorough)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsed info at bottom when enabled */}
|
||||
{oaiMode.enabled && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
Requests route through{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{backendUrl}
|
||||
</span>{" "}
|
||||
to OpenAI API. Server must have{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
DevUI is a sample app for getting started with Agent Framework.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-mono">{serverVersion || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Runtime:</span>
|
||||
<span className="font-mono capitalize">{runtime || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">UI Mode:</span>
|
||||
<span className="font-mono capitalize">{uiMode || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities section - only show if we have capability data */}
|
||||
{(serverCapabilities || authRequired !== undefined) && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
{serverCapabilities?.instrumentation !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Instrumentation:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.instrumentation ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{serverCapabilities.instrumentation ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{serverCapabilities?.openai_proxy !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">OpenAI Proxy:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.openai_proxy ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{serverCapabilities.openai_proxy ? 'Available' : 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{serverCapabilities?.deployment !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Deployment:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.deployment ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{serverCapabilities.deployment ? 'Available' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{authRequired !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Authentication:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${authRequired ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{authRequired ? 'Required' : 'Not Required'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/microsoft/agent-framework",
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Learn More about Agent Framework
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
attribute?: "class" | "data-theme" | "data-mode"
|
||||
defaultTheme?: string
|
||||
enableSystem?: boolean
|
||||
disableTransitionOnChange?: boolean
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
attribute = "class",
|
||||
defaultTheme = "dark",
|
||||
enableSystem = true,
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute={attribute}
|
||||
defaultTheme={defaultTheme}
|
||||
enableSystem={enableSystem}
|
||||
disableTransitionOnChange={disableTransitionOnChange}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Alert component - Simple alert/callout component
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* AttachmentGallery - Shows uploaded files with thumbnails and remove options
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Image, Trash2, Music } from "lucide-react";
|
||||
|
||||
export interface AttachmentItem {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string; // Data URL for preview
|
||||
type: "image" | "pdf" | "audio" | "other";
|
||||
}
|
||||
|
||||
interface AttachmentGalleryProps {
|
||||
attachments: AttachmentItem[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AttachmentGallery({
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
className = "",
|
||||
}: AttachmentGalleryProps) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${className}`}>
|
||||
{attachments.map((attachment) => (
|
||||
<AttachmentPreview
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onRemove={() => onRemoveAttachment(attachment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentPreviewProps {
|
||||
attachment: AttachmentItem;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const renderPreview = () => {
|
||||
switch (attachment.type) {
|
||||
case "image":
|
||||
return attachment.preview ? (
|
||||
<img
|
||||
src={attachment.preview}
|
||||
alt={attachment.file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full bg-gray-200">
|
||||
<Image className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "pdf":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full bg-red-50">
|
||||
<FileText className="h-6 w-6 text-red-500 mb-1" />
|
||||
<span className="text-xs text-red-600">PDF</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full bg-purple-50">
|
||||
<Music className="h-6 w-6 text-purple-500 mb-1" />
|
||||
<span className="text-xs text-purple-600">AUDIO</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
|
||||
<FileText className="h-6 w-6 text-gray-500 mb-1" />
|
||||
<span className="text-xs text-gray-600">FILE</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-16 h-16 rounded border overflow-hidden group cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={attachment.file.name}
|
||||
>
|
||||
{renderPreview()}
|
||||
|
||||
{/* Dark overlay with centered delete icon on hover */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${
|
||||
isHovered
|
||||
? 'opacity-100 backdrop-blur-sm'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<div className={`transition-all duration-200 ease-in-out ${
|
||||
isHovered
|
||||
? 'scale-100 opacity-100'
|
||||
: 'scale-75 opacity-0'
|
||||
}`}>
|
||||
<Trash2 className="h-5 w-5 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File name tooltip */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{attachment.file.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
@@ -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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* ChatMessageInput - Reusable chat input component with file upload and rich text support
|
||||
* Features: Text input, file upload, drag & drop, paste handling, attachments
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileUpload } from "@/components/ui/file-upload";
|
||||
import {
|
||||
AttachmentGallery,
|
||||
type AttachmentItem,
|
||||
} from "@/components/ui/attachment-gallery";
|
||||
import {
|
||||
SendHorizontal,
|
||||
Square,
|
||||
FileText,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { ResponseInputContent, ResponseInputTextParam, ResponseInputImageParam, ResponseInputFileParam } from "@/types/agent-framework";
|
||||
|
||||
export interface ChatMessageInputProps {
|
||||
onSubmit: (content: ResponseInputContent[]) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
isStreaming?: boolean;
|
||||
onCancel?: () => void;
|
||||
isCancelling?: boolean;
|
||||
placeholder?: string;
|
||||
showFileUpload?: boolean;
|
||||
maxAttachments?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
entityName?: string; // For placeholder text
|
||||
/** Files dropped from parent (via useDragDrop hook) */
|
||||
externalFiles?: File[];
|
||||
/** Called after external files have been processed */
|
||||
onExternalFilesProcessed?: () => void;
|
||||
}
|
||||
|
||||
export function ChatMessageInput({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
isStreaming = false,
|
||||
onCancel,
|
||||
isCancelling = false,
|
||||
placeholder,
|
||||
showFileUpload = true,
|
||||
maxAttachments = 10,
|
||||
className = "",
|
||||
disabled = false,
|
||||
entityName = "assistant",
|
||||
externalFiles,
|
||||
onExternalFilesProcessed,
|
||||
}: ChatMessageInputProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Process external files from parent (via useDragDrop hook)
|
||||
useEffect(() => {
|
||||
if (externalFiles && externalFiles.length > 0) {
|
||||
handleFilesSelected(externalFiles);
|
||||
onExternalFilesProcessed?.();
|
||||
}
|
||||
}, [externalFiles, onExternalFilesProcessed]);
|
||||
|
||||
// Constants for text-to-file conversion
|
||||
const TEXT_THRESHOLD = 10000; // 10KB threshold for converting to file
|
||||
|
||||
// Helper functions
|
||||
const getFileType = (file: File): AttachmentItem["type"] => {
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (file.type === "application/pdf") return "pdf";
|
||||
if (file.type.startsWith("audio/")) return "audio";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Detect file extension from content
|
||||
const detectFileExtension = (text: string): string => {
|
||||
const trimmed = text.trim();
|
||||
const lines = trimmed.split("\n");
|
||||
|
||||
// JSON detection
|
||||
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
|
||||
|
||||
// XML/HTML detection
|
||||
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
|
||||
|
||||
// Markdown detection (code blocks)
|
||||
if (/^```/.test(trimmed)) return ".md";
|
||||
|
||||
// TSV detection (tabs with multiple lines)
|
||||
if (/\t/.test(text) && lines.length > 1) return ".tsv";
|
||||
|
||||
// CSV detection (more strict) - need multiple lines with consistent comma patterns
|
||||
if (lines.length > 2) {
|
||||
const commaLines = lines.filter((line) => line.includes(","));
|
||||
const semicolonLines = lines.filter((line) => line.includes(";"));
|
||||
|
||||
// If >50% of lines have commas and it looks tabular
|
||||
if (commaLines.length > lines.length * 0.5) {
|
||||
const avgCommas =
|
||||
commaLines.reduce(
|
||||
(sum, line) => sum + (line.match(/,/g) || []).length,
|
||||
0
|
||||
) / commaLines.length;
|
||||
if (avgCommas >= 2) return ".csv";
|
||||
}
|
||||
|
||||
// If >50% of lines have semicolons and it looks tabular
|
||||
if (semicolonLines.length > lines.length * 0.5) {
|
||||
const avgSemicolons =
|
||||
semicolonLines.reduce(
|
||||
(sum, line) => sum + (line.match(/;/g) || []).length,
|
||||
0
|
||||
) / semicolonLines.length;
|
||||
if (avgSemicolons >= 2) return ".csv";
|
||||
}
|
||||
}
|
||||
|
||||
return ".txt";
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFilesSelected = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (attachments.length + files.length > maxAttachments) {
|
||||
console.warn(`Cannot add more than ${maxAttachments} attachments`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttachments: AttachmentItem[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const attachment: AttachmentItem = {
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
type: getFileType(file),
|
||||
};
|
||||
|
||||
// Generate preview for images
|
||||
if (file.type.startsWith("image/")) {
|
||||
try {
|
||||
attachment.preview = await readFileAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate preview:", error);
|
||||
}
|
||||
}
|
||||
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
},
|
||||
[attachments.length, maxAttachments]
|
||||
);
|
||||
|
||||
// Handle attachment removal
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await handleFilesSelected(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste events
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files: File[] = [];
|
||||
let hasProcessedText = false;
|
||||
|
||||
for (const item of items) {
|
||||
// Handle images (including screenshots)
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
const timestamp = Date.now();
|
||||
files.push(
|
||||
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle text - only process first text item (browsers often duplicate)
|
||||
else if (item.type === "text/plain" && !hasProcessedText) {
|
||||
hasProcessedText = true;
|
||||
|
||||
// We need to check the text synchronously to decide whether to prevent default
|
||||
// Unfortunately, getAsString is async, so we'll prevent default for all text
|
||||
// and then decide whether to actually create a file or manually insert the text
|
||||
e.preventDefault();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
item.getAsString((text) => {
|
||||
// Check if text should be converted to file
|
||||
const lineCount = (text.match(/\n/g) || []).length;
|
||||
const shouldConvert =
|
||||
text.length > TEXT_THRESHOLD ||
|
||||
lineCount > 50 || // Many lines suggests logs/data
|
||||
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
|
||||
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
|
||||
|
||||
if (shouldConvert) {
|
||||
// Create file for large/complex text
|
||||
const extension = detectFileExtension(text);
|
||||
const timestamp = Date.now();
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
files.push(
|
||||
new File([blob], `pasted-text-${timestamp}${extension}`, {
|
||||
type: "text/plain",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// For small text, manually insert into textarea since we prevented default
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const currentValue = textarea.value;
|
||||
const newValue =
|
||||
currentValue.slice(0, start) + text + currentValue.slice(end);
|
||||
setInputValue(newValue);
|
||||
|
||||
// Restore cursor position after the inserted text
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd =
|
||||
start + text.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process collected files
|
||||
if (files.length > 0) {
|
||||
await handleFilesSelected(files);
|
||||
|
||||
// Show notification with appropriate icon
|
||||
const message =
|
||||
files.length === 1
|
||||
? files[0].name.includes("screenshot")
|
||||
? "Screenshot added as attachment"
|
||||
: "Large text converted to file"
|
||||
: `${files.length} files added`;
|
||||
|
||||
setPasteNotification(message);
|
||||
setTimeout(() => setPasteNotification(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
isSubmitting ||
|
||||
disabled
|
||||
)
|
||||
return;
|
||||
|
||||
const messageText = inputValue.trim();
|
||||
const content: ResponseInputContent[] = [];
|
||||
|
||||
// Add text content if present
|
||||
if (messageText) {
|
||||
content.push({
|
||||
text: messageText,
|
||||
type: "input_text",
|
||||
} as ResponseInputTextParam);
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of attachments) {
|
||||
const dataUri = await readFileAsDataURL(attachment.file);
|
||||
|
||||
if (attachment.file.type.startsWith("image/")) {
|
||||
// Image attachment
|
||||
content.push({
|
||||
detail: "auto",
|
||||
type: "input_image",
|
||||
image_url: dataUri,
|
||||
} as ResponseInputImageParam);
|
||||
} else if (
|
||||
attachment.file.type === "text/plain" &&
|
||||
(attachment.file.name.includes("pasted-text-") ||
|
||||
attachment.file.name.endsWith(".txt") ||
|
||||
attachment.file.name.endsWith(".csv") ||
|
||||
attachment.file.name.endsWith(".json") ||
|
||||
attachment.file.name.endsWith(".html") ||
|
||||
attachment.file.name.endsWith(".md") ||
|
||||
attachment.file.name.endsWith(".tsv"))
|
||||
) {
|
||||
// Convert text files back to input_text
|
||||
const text = await attachment.file.text();
|
||||
content.push({
|
||||
text: text,
|
||||
type: "input_text",
|
||||
} as ResponseInputTextParam);
|
||||
} else {
|
||||
// Other file types
|
||||
const base64Data = dataUri.split(",")[1]; // Extract base64 part
|
||||
content.push({
|
||||
type: "input_file",
|
||||
file_data: base64Data,
|
||||
file_url: dataUri,
|
||||
filename: attachment.file.name,
|
||||
} as ResponseInputFileParam);
|
||||
}
|
||||
}
|
||||
|
||||
// Call the onSubmit callback
|
||||
await onSubmit(content);
|
||||
|
||||
// Clear input and attachments after successful submission
|
||||
setInputValue("");
|
||||
setAttachments([]);
|
||||
};
|
||||
|
||||
const canSendMessage =
|
||||
!disabled &&
|
||||
!isSubmitting &&
|
||||
!isStreaming &&
|
||||
(inputValue.trim() || attachments.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
|
||||
Drop files here
|
||||
</div>
|
||||
<div className="text-blue-500 dark:text-blue-500 text-xs">
|
||||
Images, PDFs, and other files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment gallery */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<AttachmentGallery
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste notification */}
|
||||
{pasteNotification && (
|
||||
<div
|
||||
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
|
||||
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
|
||||
animate-in slide-in-from-bottom-2 fade-in duration-200
|
||||
flex items-center gap-2 shadow-lg"
|
||||
>
|
||||
{pasteNotification.includes("screenshot") ? (
|
||||
<Paperclip className="h-3 w-3" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3" />
|
||||
)}
|
||||
{pasteNotification}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || `Message ${entityName}... (Shift+Enter for new line)`}
|
||||
disabled={disabled || isSubmitting || isStreaming}
|
||||
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
{showFileUpload && (
|
||||
<FileUpload
|
||||
onFilesSelected={handleFilesSelected}
|
||||
disabled={disabled || isSubmitting || isStreaming}
|
||||
/>
|
||||
)}
|
||||
{isStreaming && onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={onCancel}
|
||||
disabled={isCancelling}
|
||||
className="shrink-0 h-10 transition-all"
|
||||
title="Stop generating"
|
||||
aria-label="Stop generating response"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSendMessage}
|
||||
className="shrink-0 h-10 transition-all"
|
||||
title="Send message"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface DialogContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogFooterProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
// Close the modal when backdrop is clicked
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleContentClick = (e: React.MouseEvent) => {
|
||||
// Stop any clicks inside the content from bubbling to backdrop
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleContentMouseDown = (e: React.MouseEvent) => {
|
||||
// Prevent mousedown from bubbling during text selection
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop - handles clicks to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal content - positioned above backdrop with z-index */}
|
||||
<div
|
||||
className="relative z-10"
|
||||
onClick={handleContentClick}
|
||||
onMouseDown={handleContentMouseDown}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogContent({
|
||||
children,
|
||||
className = "",
|
||||
}: DialogContentProps) {
|
||||
// Default width classes if none provided
|
||||
const hasWidthClass = className.includes('w-[') || className.includes('w-full') || className.includes('max-w-');
|
||||
const defaultWidthClasses = hasWidthClass ? '' : 'max-w-lg w-full';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogTitle({ children, className = "" }: DialogTitleProps) {
|
||||
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function DialogDescription({ children, className = "" }: DialogDescriptionProps) {
|
||||
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
|
||||
}
|
||||
|
||||
export function DialogClose({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({ children }: DialogFooterProps) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 p-4 border-t bg-muted/50">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* FileUpload - Upload button with drag & drop support
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // in bytes
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onFilesSelected,
|
||||
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
|
||||
multiple = true,
|
||||
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: FileUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
// Size validation
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`${file.name} is too large (max ${formatFileSize(maxSize)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Type validation (basic)
|
||||
if (accept && !isFileAccepted(file, accept)) {
|
||||
errors.push(`${file.name} is not an accepted file type`);
|
||||
return;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("File upload errors:", errors);
|
||||
// In a production app, you might want to show these errors to the user
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesSelected(validFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFileSelect(e.target.files);
|
||||
// Reset input to allow selecting the same file again
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
handleFileSelect(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleButtonClick}
|
||||
disabled={disabled}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="shrink-0 transition-colors hover:bg-muted"
|
||||
title="Upload files (images, PDFs, audio)"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function isFileAccepted(file: File, accept: string): boolean {
|
||||
const acceptPatterns = accept.split(",").map((pattern) => pattern.trim());
|
||||
|
||||
return acceptPatterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
// File extension check
|
||||
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
} else if (pattern.includes("/*")) {
|
||||
// MIME type wildcard check (e.g., "image/*")
|
||||
const [mainType] = pattern.split("/");
|
||||
return file.type.startsWith(mainType + "/");
|
||||
} else {
|
||||
// Exact MIME type check
|
||||
return file.type === pattern;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(
|
||||
"animate-spin",
|
||||
{
|
||||
"h-4 w-4": size === "sm",
|
||||
"h-6 w-6": size === "md",
|
||||
"h-8 w-8": size === "lg",
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LoadingSpinner } from "./loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string
|
||||
description?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
className?: string
|
||||
fullPage?: boolean
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = "Loading...",
|
||||
description,
|
||||
size = "md",
|
||||
className,
|
||||
fullPage = false
|
||||
}: LoadingStateProps) {
|
||||
const content = (
|
||||
<div className={cn(
|
||||
"flex flex-col items-center justify-center gap-3",
|
||||
fullPage ? "min-h-[50vh]" : "py-8",
|
||||
className
|
||||
)}>
|
||||
<LoadingSpinner size={size} className="text-muted-foreground" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className={cn(
|
||||
"font-medium text-muted-foreground",
|
||||
size === "sm" && "text-sm",
|
||||
size === "lg" && "text-lg"
|
||||
)}>
|
||||
{message}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Lightweight Markdown Renderer
|
||||
*
|
||||
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
|
||||
* Handles the most common markdown patterns without bloating bundle size.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - **bold** and __bold__
|
||||
* - *italic* and _italic_
|
||||
* - `inline code`
|
||||
* - ```code blocks``` (with copy button on hover)
|
||||
* - [links](url)
|
||||
* - **[bold links](url)** and *[italic links](url)*
|
||||
* - # Headers (H1-H6)
|
||||
* - Lists (ordered and unordered)
|
||||
* - > Blockquotes
|
||||
* - Tables (| col1 | col2 |)
|
||||
* - Horizontal rules (---)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block component with copy button
|
||||
*/
|
||||
function CodeBlock({ code, language }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout and store reference
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy code:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
|
||||
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
|
||||
{language && (
|
||||
<span className="opacity-60 text-[10px] mb-1 block uppercase">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
|
||||
bg-background hover:bg-accent
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-all duration-200
|
||||
opacity-0 group-hover:opacity-100"
|
||||
title={copied ? "Copied!" : "Copy code"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-green-600 dark:text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown text into React elements
|
||||
*/
|
||||
export function MarkdownRenderer({
|
||||
content,
|
||||
className = "",
|
||||
}: MarkdownRendererProps) {
|
||||
const lines = content.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code blocks (multiline)
|
||||
if (line.trim().startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
const langMatch = line.trim().match(/^```(\w+)?/);
|
||||
const language = langMatch?.[1] || "";
|
||||
i++; // Skip opening ```
|
||||
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // Skip closing ```
|
||||
|
||||
elements.push(
|
||||
<CodeBlock
|
||||
key={elements.length}
|
||||
code={codeLines.join("\n")}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const text = headerMatch[2];
|
||||
const sizes = [
|
||||
"text-2xl",
|
||||
"text-xl",
|
||||
"text-lg",
|
||||
"text-base",
|
||||
"text-sm",
|
||||
"text-sm",
|
||||
];
|
||||
const className = `${
|
||||
sizes[level - 1]
|
||||
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
|
||||
|
||||
// Render appropriate header level
|
||||
const header =
|
||||
level === 1 ? (
|
||||
<h1 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h1>
|
||||
) : level === 2 ? (
|
||||
<h2 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h2>
|
||||
) : level === 3 ? (
|
||||
<h3 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h3>
|
||||
) : level === 4 ? (
|
||||
<h4 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h4>
|
||||
) : level === 5 ? (
|
||||
<h5 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h5>
|
||||
) : (
|
||||
<h6 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h6>
|
||||
);
|
||||
|
||||
elements.push(header);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered lists
|
||||
if (line.match(/^[\s]*[-*+]\s+/)) {
|
||||
const listItems: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
|
||||
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
|
||||
listItems.push(itemText);
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<ul
|
||||
key={elements.length}
|
||||
className="my-2 ml-4 list-disc space-y-1 break-words"
|
||||
>
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} className="text-sm break-words">
|
||||
{parseInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered lists
|
||||
if (line.match(/^[\s]*\d+\.\s+/)) {
|
||||
const listItems: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
|
||||
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
|
||||
listItems.push(itemText);
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<ol
|
||||
key={elements.length}
|
||||
className="my-2 ml-4 list-decimal space-y-1 break-words"
|
||||
>
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} className="text-sm break-words">
|
||||
{parseInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables
|
||||
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
|
||||
const tableLines: string[] = [];
|
||||
|
||||
// Collect all table lines
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim().startsWith("|") &&
|
||||
lines[i].trim().endsWith("|")
|
||||
) {
|
||||
tableLines.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
|
||||
// Parse table (need at least 2 lines: header + separator)
|
||||
if (tableLines.length >= 2) {
|
||||
const headerCells = tableLines[0]
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim());
|
||||
|
||||
// Check if second line is a separator (contains dashes)
|
||||
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
|
||||
|
||||
if (isSeparator) {
|
||||
const bodyRows = tableLines.slice(2).map((row) =>
|
||||
row
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim())
|
||||
);
|
||||
|
||||
elements.push(
|
||||
<div key={elements.length} className="my-3 overflow-x-auto">
|
||||
<table className="min-w-full border border-foreground/10 text-sm">
|
||||
<thead className="bg-foreground/5">
|
||||
<tr>
|
||||
{headerCells.map((header, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
|
||||
>
|
||||
{parseInlineMarkdown(header)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bodyRows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="border-b border-foreground/5 last:border-b-0"
|
||||
>
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td
|
||||
key={cellIdx}
|
||||
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
|
||||
>
|
||||
{parseInlineMarkdown(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a valid table, render as regular paragraphs
|
||||
for (const tableLine of tableLines) {
|
||||
elements.push(
|
||||
<p key={elements.length} className="my-1">
|
||||
{parseInlineMarkdown(tableLine)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
if (line.trim().startsWith(">")) {
|
||||
const quoteLines: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
||||
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<blockquote
|
||||
key={elements.length}
|
||||
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
|
||||
>
|
||||
{quoteLines.map((quoteLine, idx) => (
|
||||
<div key={idx} className="break-words">
|
||||
{parseInlineMarkdown(quoteLine)}
|
||||
</div>
|
||||
))}
|
||||
</blockquote>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
|
||||
elements.push(
|
||||
<hr key={elements.length} className="my-4 border-t border-border" />
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === "") {
|
||||
elements.push(<div key={elements.length} className="h-2" />);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(
|
||||
<p key={elements.length} className="my-1 break-words">
|
||||
{parseInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-content break-words ${className}`}>
|
||||
{elements}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline markdown patterns (bold, italic, code, links)
|
||||
*/
|
||||
function parseInlineMarkdown(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
// Pattern priority: code > bold > italic > links
|
||||
// This prevents conflicts between overlapping patterns
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Inline code (highest priority to avoid parsing inside code)
|
||||
const codeMatch = remaining.match(/`([^`]+)`/);
|
||||
if (codeMatch && codeMatch.index !== undefined) {
|
||||
// Add text before code
|
||||
if (codeMatch.index > 0) {
|
||||
parts.push(
|
||||
<span key={key++}>
|
||||
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Add code
|
||||
parts.push(
|
||||
<code
|
||||
key={key++}
|
||||
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
|
||||
>
|
||||
{codeMatch[1]}
|
||||
</code>
|
||||
);
|
||||
|
||||
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No more special patterns, parse remaining text for bold/italic/links
|
||||
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
|
||||
break;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bold, italic, and links (after code has been extracted)
|
||||
*/
|
||||
function parseBoldItalicLinks(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Try to match patterns in order
|
||||
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
|
||||
const patterns = [
|
||||
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
|
||||
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
|
||||
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
|
||||
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
|
||||
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
|
||||
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
|
||||
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
|
||||
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
|
||||
{ regex: /_(.+?)_/, component: "em" }, // _italic_
|
||||
];
|
||||
|
||||
let matched = false;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = remaining.match(pattern.regex);
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
// Add text before match
|
||||
if (match.index > 0) {
|
||||
parts.push(remaining.slice(0, match.index));
|
||||
}
|
||||
|
||||
// Add matched element
|
||||
if (pattern.component === "strong") {
|
||||
parts.push(
|
||||
<strong key={key++} className="font-semibold">
|
||||
{match[1]}
|
||||
</strong>
|
||||
);
|
||||
} else if (pattern.component === "em") {
|
||||
parts.push(
|
||||
<em key={key++} className="italic">
|
||||
{match[1]}
|
||||
</em>
|
||||
);
|
||||
} else if (pattern.component === "strong-link") {
|
||||
// **[text](url)** - Bold link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<strong key={key++} className="font-semibold">
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
</strong>
|
||||
);
|
||||
} else if (pattern.component === "em-link") {
|
||||
// *[text](url)* - Italic link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<em key={key++} className="italic">
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
</em>
|
||||
);
|
||||
} else if (pattern.component === "link") {
|
||||
// [text](url) - Regular link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
remaining = remaining.slice(match.index + match[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No pattern matched, add remaining text and exit
|
||||
if (!matched) {
|
||||
if (remaining.length > 0) {
|
||||
parts.push(remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -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-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Simple toast notification component
|
||||
* Displays floating notifications in the top-right corner
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, type = "info", duration = 4000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300); // Wait for fade out animation
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const bgColorClass = {
|
||||
info: "bg-primary/10 border-primary/20",
|
||||
success: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
|
||||
warning: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
|
||||
error: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800",
|
||||
}[type];
|
||||
|
||||
const textColorClass = {
|
||||
info: "text-primary",
|
||||
success: "text-green-800 dark:text-green-200",
|
||||
warning: "text-orange-800 dark:text-orange-200",
|
||||
error: "text-red-800 dark:text-red-200",
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${
|
||||
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-4"
|
||||
} ${bgColorClass}`}
|
||||
>
|
||||
<p className={`text-sm flex-1 ${textColorClass}`}>{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300);
|
||||
}}
|
||||
className={`flex-shrink-0 hover:opacity-70 transition-opacity ${textColorClass}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast container for managing multiple toasts
|
||||
export interface ToastData {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastData[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={() => onRemove(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Gallery data exports
|
||||
*/
|
||||
|
||||
export * from './sample-entities';
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Sample entities for the gallery - curated examples to help users learn Agent Framework
|
||||
*/
|
||||
|
||||
export interface EnvVarRequirement {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export interface SampleEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "agent" | "workflow";
|
||||
url: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
difficulty: "beginner" | "intermediate" | "advanced";
|
||||
features: string[];
|
||||
requiredEnvVars?: EnvVarRequirement[];
|
||||
}
|
||||
|
||||
export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
// Beginner Agents
|
||||
{
|
||||
id: "foundry-weather-agent",
|
||||
name: "Azure AI Weather Agent",
|
||||
description:
|
||||
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",
|
||||
tags: ["azure-ai", "foundry", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Azure AI Agent integration",
|
||||
"Azure CLI authentication",
|
||||
"Mock weather tools",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "FOUNDRY_PROJECT_ENDPOINT",
|
||||
description: "Azure AI Foundry project endpoint URL",
|
||||
required: true,
|
||||
example: "https://your-project.api.azureml.ms",
|
||||
},
|
||||
{
|
||||
name: "FOUNDRY_MODEL",
|
||||
description: "Name of the deployed model in Azure AI Foundry",
|
||||
required: true,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "weather-agent-azure",
|
||||
name: "Azure OpenAI Weather Agent",
|
||||
description:
|
||||
"Weather agent using Azure OpenAI with API key authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",
|
||||
tags: ["azure", "openai", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Azure OpenAI integration",
|
||||
"API key authentication",
|
||||
"Function calling",
|
||||
"Mock weather tools",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "AZURE_OPENAI_API_KEY",
|
||||
description: "Azure OpenAI API key",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "AZURE_OPENAI_MODEL",
|
||||
description: "Name of the deployed model in Azure OpenAI",
|
||||
required: true,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "AZURE_OPENAI_ENDPOINT",
|
||||
description: "Azure OpenAI endpoint URL",
|
||||
required: true,
|
||||
example: "https://your-resource.openai.azure.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Beginner Workflows
|
||||
{
|
||||
id: "spam-workflow",
|
||||
name: "Spam Detection Workflow",
|
||||
description:
|
||||
"5-step workflow demonstrating email spam detection with branching logic",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",
|
||||
tags: ["workflow", "branching", "multi-step"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Sequential execution",
|
||||
"Conditional branching",
|
||||
"Mock spam detection",
|
||||
],
|
||||
},
|
||||
|
||||
// Advanced Workflows
|
||||
{
|
||||
id: "fanout-workflow",
|
||||
name: "Complex Fan-In/Fan-Out Workflow",
|
||||
description:
|
||||
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",
|
||||
tags: ["workflow", "fan-out", "fan-in", "parallel"],
|
||||
author: "Microsoft",
|
||||
difficulty: "advanced",
|
||||
features: [
|
||||
"Fan-out pattern",
|
||||
"Parallel execution",
|
||||
"Complex state management",
|
||||
"Multi-stage processing",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Group samples by category for better organization
|
||||
export const SAMPLE_CATEGORIES = {
|
||||
all: SAMPLE_ENTITIES,
|
||||
agents: SAMPLE_ENTITIES.filter((e) => e.type === "agent"),
|
||||
workflows: SAMPLE_ENTITIES.filter((e) => e.type === "workflow"),
|
||||
beginner: SAMPLE_ENTITIES.filter((e) => e.difficulty === "beginner"),
|
||||
intermediate: SAMPLE_ENTITIES.filter((e) => e.difficulty === "intermediate"),
|
||||
advanced: SAMPLE_ENTITIES.filter((e) => e.difficulty === "advanced"),
|
||||
};
|
||||
|
||||
// Get difficulty color for badges
|
||||
export const getDifficultyColor = (difficulty: SampleEntity["difficulty"]) => {
|
||||
switch (difficulty) {
|
||||
case "beginner":
|
||||
return "bg-green-100 text-green-700 border-green-200";
|
||||
case "intermediate":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
case "advanced":
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-200";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useCancellableRequest, isAbortError } from './useCancellableRequest';
|
||||
export { useDragDrop } from './use-drag-drop';
|
||||
export type { UseDragDropOptions, UseDragDropReturn } from './use-drag-drop';
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* useDragDrop - Hook for handling drag and drop file uploads at parent level
|
||||
* Provides drag state and handlers that can be spread on a container element
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface UseDragDropOptions {
|
||||
/** Called when files are dropped */
|
||||
onDrop?: (files: File[]) => void;
|
||||
/** Whether drag/drop is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDragDropReturn {
|
||||
/** Whether a drag is currently over the drop zone */
|
||||
isDragOver: boolean;
|
||||
/** Files that were dropped (cleared after processing) */
|
||||
droppedFiles: File[];
|
||||
/** Clear the dropped files after they've been processed */
|
||||
clearDroppedFiles: () => void;
|
||||
/** Event handlers to spread on the container element */
|
||||
dragHandlers: {
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useDragDrop(options: UseDragDropOptions = {}): UseDragDropReturn {
|
||||
const { onDrop, disabled = false } = options;
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
setDroppedFiles(files);
|
||||
onDrop?.(files);
|
||||
}
|
||||
}, [disabled, onDrop]);
|
||||
|
||||
const clearDroppedFiles = useCallback(() => {
|
||||
setDroppedFiles([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
droppedFiles,
|
||||
clearDroppedFiles,
|
||||
dragHandlers: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDragOver: handleDragOver,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Custom hook for managing cancellable requests with AbortController
|
||||
* Reduces duplication across agent and workflow views
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Hook for managing cancellable requests with AbortController
|
||||
* @returns Object with cancellation state and methods
|
||||
*/
|
||||
export function useCancellableRequest() {
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Creates a new AbortController and returns its signal
|
||||
* Resets the cancelling state
|
||||
*/
|
||||
const createAbortSignal = useCallback((): AbortSignal => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
setIsCancelling(false);
|
||||
return abortControllerRef.current.signal;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancels the current request if one exists
|
||||
*/
|
||||
const handleCancel = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
setIsCancelling(true);
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Resets the cancelling state - useful in error handlers
|
||||
*/
|
||||
const resetCancelling = useCallback(() => {
|
||||
setIsCancelling(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup function to be called when component unmounts
|
||||
*/
|
||||
const cleanup = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isCancelling,
|
||||
createAbortSignal,
|
||||
handleCancel,
|
||||
resetCancelling,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to check if an error is an AbortError
|
||||
* @param error - The error to check
|
||||
* @returns true if the error is an AbortError
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError';
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.48 0.18 290);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.62 0.20 290);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mermaid diagram styles removed - visualization coming soon */
|
||||
|
||||
/* Style workflow completion/error states */
|
||||
.workflow-chat-view .border-green-200 {
|
||||
@apply border-emerald-200;
|
||||
}
|
||||
|
||||
.workflow-chat-view .bg-green-50 {
|
||||
@apply bg-emerald-50;
|
||||
}
|
||||
|
||||
.workflow-chat-view .bg-green-100 {
|
||||
@apply bg-emerald-100;
|
||||
}
|
||||
|
||||
.workflow-chat-view .text-green-600 {
|
||||
@apply text-emerald-600;
|
||||
}
|
||||
|
||||
.workflow-chat-view .text-green-700 {
|
||||
@apply text-emerald-700;
|
||||
}
|
||||
|
||||
.workflow-chat-view .text-green-800 {
|
||||
@apply text-emerald-800;
|
||||
}
|
||||
|
||||
/* HIL Timeline Item Animations */
|
||||
.highlight-attention {
|
||||
animation: highlight-flash 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-flash {
|
||||
0% {
|
||||
background-color: rgb(251 146 60 / 0.3);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulsing glow effect for HIL waiting state */
|
||||
.hil-waiting-glow {
|
||||
box-shadow:
|
||||
0 0 0 0 rgb(251 146 60 / 0.4),
|
||||
inset 0 0 0 1px rgb(251 146 60 / 0.2);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 0 rgb(251 146 60 / 0.4),
|
||||
inset 0 0 0 1px rgb(251 146 60 / 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 20px 5px rgb(251 146 60 / 0.2),
|
||||
inset 0 0 0 2px rgb(251 146 60 / 0.3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
import { initStreamingState } from "./services/api"
|
||||
|
||||
// Initialize streaming state management (clears expired states)
|
||||
initStreamingState();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user