chore: import upstream snapshot with attribution
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) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
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) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# AG-UI Handoff Workflow Demo
|
||||
|
||||
This demo is a full custom AG-UI application built on top of the new workflow abstractions in `agent_framework_ag_ui`.
|
||||
|
||||
It includes:
|
||||
|
||||
- A **backend** FastAPI AG-UI endpoint serving a **HandoffBuilder workflow** with:
|
||||
- `triage_agent`
|
||||
- `refund_agent`
|
||||
- `order_agent`
|
||||
- Required **tool approval checkpoints**:
|
||||
- `submit_refund` (`approval_mode="always_require"`)
|
||||
- `submit_replacement` (`approval_mode="always_require"`)
|
||||
- A second **request-info resume** step (order agent asks for shipping preference)
|
||||
- A **frontend** React app that consumes AG-UI SSE events, renders workflow cards, and sends `resume.interrupts` payloads.
|
||||
|
||||
The backend uses Azure OpenAI responses and supports intent-driven, non-linear handoff routing.
|
||||
|
||||
This demo keeps workflow state per `thread_id`. When the assistant ends a case with `Case complete.`, the UI blocks
|
||||
later top-level input on that same thread and asks the user to start a new case explicitly instead of resuming a
|
||||
terminated workflow.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
- `backend/server.py` - FastAPI + AG-UI endpoint + Handoff workflow
|
||||
- `frontend/` - Vite + React AG-UI client UI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- npm 9+
|
||||
- Azure AI project + model deployment configured in environment variables:
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`
|
||||
- `FOUNDRY_MODEL`
|
||||
|
||||
## 1) Run Backend
|
||||
|
||||
From the Python repo root:
|
||||
|
||||
```bash
|
||||
cd python
|
||||
uv sync
|
||||
uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py
|
||||
```
|
||||
|
||||
Backend default URL:
|
||||
|
||||
- `http://127.0.0.1:8891`
|
||||
- AG-UI endpoint: `POST http://127.0.0.1:8891/handoff_demo`
|
||||
|
||||
## 2) Install Frontend Packages (npm)
|
||||
|
||||
From the `python/` directory (where Step 1 left you):
|
||||
|
||||
```bash
|
||||
cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3) Run Frontend Locally
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend default URL:
|
||||
|
||||
- `http://127.0.0.1:5173`
|
||||
|
||||
If you changed backend host/port, run with:
|
||||
|
||||
```bash
|
||||
VITE_BACKEND_URL=http://127.0.0.1:8891 npm run dev
|
||||
```
|
||||
|
||||
## 4) Demo Flow to Verify
|
||||
|
||||
1. Click one of the starter prompts (or type a refund request).
|
||||
2. Refund Agent asks for an order number; reply with a numeric ID (for example: `987654`).
|
||||
3. If your initial request did not explicitly choose refund vs replacement, the agent asks a clarifying choice question.
|
||||
4. Wait for the `submit_refund` reviewer interrupt (built from your provided order ID).
|
||||
5. In the **HITL Reviewer Console** modal, click **Approve Tool Call**.
|
||||
6. If you asked for replacement, the Order agent asks for shipping preference; reply in the chat input (for example: `expedited`).
|
||||
7. When replacement is requested, wait for the `submit_replacement` reviewer interrupt and approve/reject it.
|
||||
8. If you asked for refund-only, the flow should close without replacement/shipping prompts.
|
||||
9. Confirm the case snapshot updates and workflow completion.
|
||||
10. After the case closes, another top-level message on the same thread is rejected with a notice.
|
||||
11. Click **Start New Case** to begin a fresh thread.
|
||||
|
||||
## Important: `require_per_service_call_history_persistence`
|
||||
|
||||
All agents participating in a handoff workflow **must** be constructed with
|
||||
`require_per_service_call_history_persistence=True`. The `HandoffBuilder` will
|
||||
raise a `ValueError` at build time if any participant is missing this flag.
|
||||
|
||||
**Why this is required:** Handoff workflows use middleware that short-circuits
|
||||
tool calls via `MiddlewareTermination` when a handoff tool is invoked. Without
|
||||
per-service-call history persistence, local history providers would persist tool
|
||||
results that the service never received, causing call/result mismatches on
|
||||
subsequent turns.
|
||||
|
||||
```python
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="my_agent",
|
||||
require_per_service_call_history_persistence=True, # Required for handoff
|
||||
)
|
||||
```
|
||||
|
||||
## What This Validates
|
||||
|
||||
- `add_agent_framework_fastapi_endpoint(...)` with `AgentFrameworkWorkflow(workflow_factory=...)`
|
||||
- Thread-scoped workflow state across turns
|
||||
- `RUN_FINISHED.interrupt` pause behavior
|
||||
- `resume.interrupts` continuation behavior
|
||||
- JSON resume payload coercion for `Content` and `list[Message]` workflow response types
|
||||
- Intent-driven routing between triage, refund, and order specialists (no forced linear path)
|
||||
- Multiple HITL approvals in one case (`submit_refund` + `submit_replacement`)
|
||||
@@ -0,0 +1,366 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AG-UI handoff workflow demo backend.
|
||||
|
||||
This demo exposes a dynamic HandoffBuilder workflow through AG-UI.
|
||||
It intentionally includes two interrupt styles:
|
||||
|
||||
1. Tool approval (`function_approval_request`) for `submit_refund` and `submit_replacement`
|
||||
2. Follow-up human input (`HandoffAgentUserRequest`) when an agent needs user details
|
||||
|
||||
Run this server and pair it with the frontend in `../frontend`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.ag_ui import AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.orchestrations import HandoffBuilder
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
|
||||
"""Capture a refund request for manual review before processing."""
|
||||
return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def submit_replacement(order_id: str, shipping_preference: str, replacement_note: str) -> str:
|
||||
"""Capture a replacement request for manual review before processing."""
|
||||
return (
|
||||
f"replacement recorded for order {order_id} (shipping: {shipping_preference}) with details: {replacement_note}"
|
||||
)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def lookup_order_details(order_id: str) -> dict[str, str]:
|
||||
"""Return synthetic order details for a given order ID."""
|
||||
normalized_order_id = "".join(ch for ch in order_id if ch.isdigit()) or order_id
|
||||
rng = random.Random(normalized_order_id)
|
||||
catalog = [
|
||||
"Wireless Headphones",
|
||||
"Mechanical Keyboard",
|
||||
"Gaming Mouse",
|
||||
"27-inch Monitor",
|
||||
"USB-C Dock",
|
||||
"Bluetooth Speaker",
|
||||
"Laptop Stand",
|
||||
]
|
||||
item_name = catalog[rng.randrange(len(catalog))]
|
||||
amount = f"${rng.randint(39, 349)}.{rng.randint(0, 99):02d}"
|
||||
purchase_date = f"2025-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
||||
return {
|
||||
"order_id": normalized_order_id,
|
||||
"item_name": item_name,
|
||||
"amount": amount,
|
||||
"currency": "USD",
|
||||
"purchase_date": purchase_date,
|
||||
"status": "delivered",
|
||||
}
|
||||
|
||||
|
||||
def create_agents() -> tuple[Agent, Agent, Agent]:
|
||||
"""Create triage, refund, and order agents for the handoff workflow."""
|
||||
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
triage = Agent(
|
||||
id="triage_agent",
|
||||
name="triage_agent",
|
||||
instructions=(
|
||||
"You are the customer support triage agent.\n"
|
||||
"Routing policy:\n"
|
||||
"1. Route refund-related requests to refund_agent.\n"
|
||||
"2. Route replacement/shipping requests to order_agent.\n"
|
||||
"3. Do not force replacement if the user asked for refund only.\n"
|
||||
"4. If the issue is fully resolved, send a concise wrap-up that ends with exactly: Case complete."
|
||||
),
|
||||
client=client,
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
refund = Agent(
|
||||
id="refund_agent",
|
||||
name="refund_agent",
|
||||
instructions=(
|
||||
"You are the refund specialist.\n"
|
||||
"Workflow policy:\n"
|
||||
"1. If order_id is missing, ask only for order_id.\n"
|
||||
"2. Once order_id is available, call lookup_order_details(order_id) to retrieve item and amount.\n"
|
||||
"3. Do not ask the customer how much they paid unless lookup_order_details fails.\n"
|
||||
"4. If user intent is ambiguous, ask one clear choice question and wait for the answer:\n"
|
||||
" refund only, replacement only, or both.\n"
|
||||
" Do not call submit_refund until this choice is known.\n"
|
||||
"5. Gather a short refund reason from user context if needed.\n"
|
||||
"6. If the user wants a refund (refund-only or both),\n"
|
||||
" call submit_refund with order_id, amount (from lookup), and refund_description.\n"
|
||||
"7. After approval and successful refund submission:\n"
|
||||
" - If the user explicitly requested replacement/exchange, handoff to order_agent.\n"
|
||||
" - If the user asked for refund only, do not hand off for replacement.\n"
|
||||
" Finalize in this agent and end with exactly: Case complete.\n"
|
||||
"8. If the user wants replacement only and no refund, handoff to order_agent directly."
|
||||
),
|
||||
client=client,
|
||||
tools=[lookup_order_details, submit_refund],
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
order = Agent(
|
||||
id="order_agent",
|
||||
name="order_agent",
|
||||
instructions=(
|
||||
"You are the order specialist.\n"
|
||||
"Only handle replacement/exchange/shipping tasks.\n"
|
||||
"1. If replacement intent is confirmed but shipping preference is missing,\n"
|
||||
" ask for shipping preference (standard or expedited).\n"
|
||||
"2. If order_id is missing, ask for order_id.\n"
|
||||
"3. Once order_id and shipping preference are known,\n"
|
||||
" call submit_replacement(order_id, shipping_preference, replacement_note).\n"
|
||||
"4. While the replacement tool call is pending approval, do not claim completion.\n"
|
||||
"5. If you receive a submit_replacement function result,\n"
|
||||
" approval has already occurred and submission succeeded.\n"
|
||||
"6. Immediately send a final customer-facing confirmation and end with exactly: Case complete.\n"
|
||||
"If the user wants refund only and no replacement, do not ask shipping questions.\n"
|
||||
"Acknowledge and hand off back to triage_agent for final closure.\n"
|
||||
"Do not fabricate tool outputs."
|
||||
),
|
||||
client=client,
|
||||
tools=[lookup_order_details, submit_replacement],
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
return triage, refund, order
|
||||
|
||||
|
||||
def is_case_complete_text(text: str) -> bool:
|
||||
"""Return True when a message ends with the explicit demo completion marker."""
|
||||
|
||||
return text.strip().lower().endswith("case complete.")
|
||||
|
||||
|
||||
def _termination_condition(conversation: list[Message]) -> bool:
|
||||
"""Stop when any assistant emits an explicit completion marker."""
|
||||
|
||||
for message in reversed(conversation):
|
||||
if message.role != "assistant":
|
||||
continue
|
||||
if is_case_complete_text(message.text or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_handoff_workflow() -> Workflow:
|
||||
"""Build the demo HandoffBuilder workflow."""
|
||||
|
||||
triage, refund, order = create_agents()
|
||||
builder = HandoffBuilder(
|
||||
name="ag_ui_handoff_workflow_demo",
|
||||
participants=[triage, refund, order],
|
||||
termination_condition=_termination_condition,
|
||||
)
|
||||
|
||||
# Explicit handoff topology (instead of default mesh) so routing is enforced in orchestration,
|
||||
# not only implied by prompt instructions.
|
||||
(
|
||||
builder
|
||||
.add_handoff(
|
||||
triage,
|
||||
[refund],
|
||||
description="Route when the user requests refunds, damaged-item claims, or refund status updates.",
|
||||
)
|
||||
.add_handoff(
|
||||
triage,
|
||||
[order],
|
||||
description="Route when the user requests replacement, exchange, shipping preference, or shipment changes.",
|
||||
)
|
||||
.add_handoff(
|
||||
refund,
|
||||
[order],
|
||||
description="Route after refund work only if replacement/exchange logistics are explicitly needed.",
|
||||
)
|
||||
.add_handoff(
|
||||
refund,
|
||||
[triage],
|
||||
description="Route back for final case closure when refund-only work is complete.",
|
||||
)
|
||||
.add_handoff(
|
||||
order,
|
||||
[triage],
|
||||
description="Route back after replacement/shipping tasks are complete for final closure.",
|
||||
)
|
||||
.add_handoff(
|
||||
order,
|
||||
[refund],
|
||||
description="Route to refund specialist if the user pivots from replacement to refund processing.",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.with_start_agent(triage).build()
|
||||
|
||||
|
||||
def create_closed_case_notice_workflow() -> Workflow:
|
||||
"""Build a tiny workflow that explains why a completed case cannot continue."""
|
||||
|
||||
@executor(id="closed_case_notice")
|
||||
async def closed_case_notice(message: Message | None, ctx: WorkflowContext[None, str]) -> None:
|
||||
del message
|
||||
await ctx.yield_output(
|
||||
"Your case is complete, but you're trying to do something new. Please start a new thread."
|
||||
)
|
||||
|
||||
return WorkflowBuilder(start_executor=closed_case_notice).build()
|
||||
|
||||
|
||||
class DemoHandoffWorkflow(AgentFrameworkWorkflow):
|
||||
"""Workflow wrapper that blocks new top-level input on completed demo threads."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
workflow_factory=lambda _thread_id: create_handoff_workflow(),
|
||||
name="ag_ui_handoff_workflow_demo",
|
||||
description="Dynamic handoff workflow demo with tool approvals and request_info resumes.",
|
||||
)
|
||||
self._completed_threads: set[str] = set()
|
||||
self._closed_case_notice_runner = AgentFrameworkWorkflow(workflow=create_closed_case_notice_workflow())
|
||||
|
||||
async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[Any]:
|
||||
"""Intercept completed threads and return a helpful notice instead of resuming them."""
|
||||
|
||||
thread_id = self._thread_id_from_input(input_data)
|
||||
has_messages = isinstance(input_data.get("messages"), list) and len(input_data.get("messages", [])) > 0
|
||||
has_resume = input_data.get("resume") is not None
|
||||
|
||||
if thread_id in self._completed_threads and has_messages and not has_resume:
|
||||
async for event in self._closed_case_notice_runner.run(input_data):
|
||||
yield event
|
||||
return
|
||||
|
||||
message_text_by_id: dict[str, str] = {}
|
||||
case_completed_this_run = False
|
||||
|
||||
async for event in super().run(input_data):
|
||||
event_type = getattr(event, "type", None)
|
||||
if event_type == "TEXT_MESSAGE_START":
|
||||
message_id = getattr(event, "message_id", None)
|
||||
if isinstance(message_id, str):
|
||||
message_text_by_id[message_id] = ""
|
||||
elif event_type == "TEXT_MESSAGE_CONTENT":
|
||||
message_id = getattr(event, "message_id", None)
|
||||
delta = getattr(event, "delta", None)
|
||||
if isinstance(message_id, str) and isinstance(delta, str):
|
||||
message_text_by_id[message_id] = f"{message_text_by_id.get(message_id, '')}{delta}"
|
||||
elif event_type == "TEXT_MESSAGE_END":
|
||||
message_id = getattr(event, "message_id", None)
|
||||
if isinstance(message_id, str):
|
||||
final_text = message_text_by_id.pop(message_id, "")
|
||||
if is_case_complete_text(final_text):
|
||||
case_completed_this_run = True
|
||||
|
||||
yield event
|
||||
|
||||
if case_completed_this_run:
|
||||
self._completed_threads.add(thread_id)
|
||||
self.clear_thread_workflow(thread_id)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
app = FastAPI(title="AG-UI Handoff Workflow Demo")
|
||||
|
||||
cors_origins = [
|
||||
origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://127.0.0.1:5173").split(",") if origin.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
demo_workflow = DemoHandoffWorkflow()
|
||||
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=demo_workflow,
|
||||
path="/handoff_demo",
|
||||
)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the AG-UI demo backend."""
|
||||
|
||||
# Configure logging format
|
||||
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(level=logging.INFO, format=log_format)
|
||||
|
||||
# Add file handler for persistent logging
|
||||
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ag_ui_handoff_demo.log")
|
||||
try:
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760,
|
||||
backupCount=5, # 10MB max size, keep 5 backups
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
|
||||
# Add file handler to root logger
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
print(f"Logging to file: {log_file}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set up file logging: {e}")
|
||||
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
port = int(os.getenv("PORT", "8891"))
|
||||
|
||||
print(f"AG-UI handoff demo backend running at http://{host}:{port}")
|
||||
print("AG-UI endpoint: POST /handoff_demo")
|
||||
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<!-- Copyright (c) Microsoft. All rights reserved. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AG-UI Handoff Workflow Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ag-ui-handoff-workflow-demo-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,559 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
:root {
|
||||
--page-bg: #edf4f8;
|
||||
--panel-bg: #fdfdfd;
|
||||
--ink: #132534;
|
||||
--muted: #607487;
|
||||
--line: #c6d6e2;
|
||||
--teal: #1f9d8b;
|
||||
--teal-dark: #11756a;
|
||||
--amber: #ff9a3c;
|
||||
--salmon: #ef6b57;
|
||||
--shadow: 0 20px 45px rgb(15 35 51 / 14%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Avenir Next", "Helvetica Neue", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgb(31 157 139 / 20%) 0%, transparent 28%),
|
||||
radial-gradient(circle at 88% 18%, rgb(255 154 60 / 20%) 0%, transparent 30%),
|
||||
linear-gradient(150deg, #eff6fa 0%, #dceaf3 46%, #e7f1f6 100%);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
animation: fade-in 320ms ease-out;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 6px 0 8px;
|
||||
font-size: clamp(1.6rem, 2.8vw, 2.4rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
max-width: 72ch;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 20px rgb(19 37 52 / 8%);
|
||||
}
|
||||
|
||||
.status-pill span {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-pill[data-running="true"] {
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-grid div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(180deg, #fefefe 0%, #f2f7fa 100%);
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
display: block;
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.snapshot-grid strong[data-state="approved"] {
|
||||
color: var(--teal-dark);
|
||||
}
|
||||
|
||||
.snapshot-grid strong[data-state="rejected"] {
|
||||
color: #aa3228;
|
||||
}
|
||||
|
||||
.diagnostics-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.diagnostics-grid div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(180deg, #fefefe 0%, #f2f7fa 100%);
|
||||
}
|
||||
|
||||
.diagnostics-grid span {
|
||||
display: block;
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.diagnostics-timestamp {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diagnostics-raw {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #f5f9fb;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.diagnostics-raw summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diagnostics-raw pre {
|
||||
margin: 10px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.diagnostics-history {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diagnostics-history h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diagnostics-history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.agent-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #f5fafc;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.agent-pill[data-seen="true"] {
|
||||
color: #35506a;
|
||||
}
|
||||
|
||||
.agent-pill[data-active="true"] {
|
||||
border-color: var(--teal);
|
||||
color: var(--teal-dark);
|
||||
background: rgb(31 157 139 / 10%);
|
||||
}
|
||||
|
||||
.interrupt-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.interrupt-body p {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.approval-details {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #f5f9fb;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.approval-details pre {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.approval-inline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.approval-launch {
|
||||
width: fit-content;
|
||||
border: 1px solid var(--teal);
|
||||
border-radius: 10px;
|
||||
background: rgb(31 157 139 / 12%);
|
||||
color: var(--teal-dark);
|
||||
font-weight: 700;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.approval-actions button,
|
||||
.case-reset,
|
||||
.starter-prompts button,
|
||||
.chat-input button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.approval-actions button:disabled,
|
||||
.case-reset:disabled,
|
||||
.starter-prompts button:disabled,
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.approval-actions .approve {
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.approval-actions .defer {
|
||||
background: #ecf3f8;
|
||||
border: 1px solid #bdcfdc;
|
||||
color: #345267;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.approval-actions .reject {
|
||||
background: var(--salmon);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.approval-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgb(7 18 29 / 52%);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.approval-modal {
|
||||
width: min(860px, calc(100vw - 40px));
|
||||
border-radius: 18px;
|
||||
border: 1px solid #89a7ba;
|
||||
background: #fdfefe;
|
||||
box-shadow: 0 28px 60px rgb(5 18 30 / 38%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.approval-modal-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-modal-header h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.approval-modal-label {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--teal-dark);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.approval-modal-close {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #f4f8fb;
|
||||
color: #3d5a70;
|
||||
font-weight: 700;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.starter-prompts {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pending-empty-state {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.case-reset {
|
||||
width: fit-content;
|
||||
border: 1px solid #bdcfdc;
|
||||
background: #ecf3f8;
|
||||
color: #345267;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.starter-prompts button {
|
||||
text-align: left;
|
||||
background: linear-gradient(125deg, #fff8ef 0%, #ffe7cf 100%);
|
||||
border: 1px solid #f0ca97;
|
||||
padding: 10px 12px;
|
||||
color: #7b4a12;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
background: #fefefe;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 84%;
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe8f1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-bubble header {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-bubble p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-bubble[data-role="assistant"] {
|
||||
justify-self: start;
|
||||
background: #f4f9fc;
|
||||
}
|
||||
|
||||
.chat-bubble[data-role="user"] {
|
||||
justify-self: end;
|
||||
border-color: #94d2c6;
|
||||
background: #dff5ef;
|
||||
}
|
||||
|
||||
.chat-bubble[data-role="system"] {
|
||||
justify-self: center;
|
||||
max-width: 100%;
|
||||
border-style: dashed;
|
||||
background: #fef6f2;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f8fbfd;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
border: 1px solid #b7cad8;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.96rem;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
background: linear-gradient(125deg, var(--teal) 0%, var(--teal-dark) 100%);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user