mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: (ag-ui): Add Workflow Support, Harden Streaming Semantics, and add Dynamic Handoff Demo (#3911)
* fix Workflow.as_agent() streaming regression in ag-ui * Address PR feedback * workflows wip * wip * wip * Workflow AG-UI demo * Fixes for handoff workflow demo * Fixes to workflows support in AG-UI * Fixes * Add headers to some demo files * Fix comment * Fixes for store * Make _input_schema lazy-loaded * fix mypy * revert session change to handoff only for now --------- Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b1c7c7c844
commit
d8b9409e96
@@ -32,6 +32,8 @@ def _create_agent() -> Any:
|
||||
name="HostedAgent",
|
||||
instructions="You are a helpful assistant hosted in Azure Functions.",
|
||||
)
|
||||
|
||||
|
||||
# </create_agent>
|
||||
|
||||
# <host_agent>
|
||||
|
||||
@@ -21,7 +21,6 @@ Run:
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import AgentSession
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework.redis import RedisContextProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
|
||||
## 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:
|
||||
- `AZURE_AI_PROJECT_ENDPOINT`
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME`
|
||||
|
||||
## 1) Run Backend
|
||||
|
||||
From the Python repo root:
|
||||
|
||||
```bash
|
||||
cd /Users/evmattso/git/agent-framework/python
|
||||
uv sync
|
||||
uv run python samples/demos/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)
|
||||
|
||||
```bash
|
||||
cd /Users/evmattso/git/agent-framework/python/samples/demos/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.
|
||||
|
||||
## 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,292 @@
|
||||
# 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
|
||||
|
||||
import uvicorn
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
Message,
|
||||
Workflow,
|
||||
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.azure import AzureOpenAIResponsesClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
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,
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
return triage, refund, order
|
||||
|
||||
|
||||
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
|
||||
text = (message.text or "").strip().lower()
|
||||
if text.endswith("case complete."):
|
||||
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_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 = AgentFrameworkWorkflow(
|
||||
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.",
|
||||
)
|
||||
|
||||
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,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>
|
||||
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": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type AgUiEvent = Record<string, unknown> & { type: string };
|
||||
|
||||
type AgentId = "triage_agent" | "refund_agent" | "order_agent";
|
||||
|
||||
interface Interrupt {
|
||||
id: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface RequestInfoPayload {
|
||||
request_id?: string;
|
||||
source_executor_id?: string;
|
||||
request_type?: string;
|
||||
response_type?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface DisplayMessage {
|
||||
id: string;
|
||||
role: "assistant" | "user" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface CaseSnapshot {
|
||||
orderId: string;
|
||||
refundAmount: string;
|
||||
refundApproved: "pending" | "approved" | "rejected";
|
||||
shippingPreference: string;
|
||||
}
|
||||
|
||||
interface UsageDiagnostics {
|
||||
runId: string;
|
||||
inputTokenCount?: number;
|
||||
outputTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
recordedAt: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const KNOWN_AGENTS: AgentId[] = ["triage_agent", "refund_agent", "order_agent"];
|
||||
|
||||
const AGENT_LABELS: Record<AgentId, string> = {
|
||||
triage_agent: "Triage",
|
||||
refund_agent: "Refund",
|
||||
order_agent: "Order",
|
||||
};
|
||||
|
||||
const STARTER_PROMPTS = [
|
||||
"My order 12345 arrived damaged and I need a refund.",
|
||||
"Help me with a damaged-order refund and replacement.",
|
||||
];
|
||||
|
||||
function randomId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `id-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getValue(source: Record<string, unknown>, ...keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getString(source: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
const value = getValue(source, ...keys);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getObject(source: Record<string, unknown>, ...keys: string[]): Record<string, unknown> | undefined {
|
||||
const value = getValue(source, ...keys);
|
||||
return isObject(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function safeParseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextFromMessagePayload(messagePayload: unknown): string {
|
||||
if (!isObject(messagePayload)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const directText = getString(messagePayload, "text", "content");
|
||||
if (directText && directText.length > 0) {
|
||||
return directText;
|
||||
}
|
||||
|
||||
const contentItems = getValue(messagePayload, "contents", "content");
|
||||
if (Array.isArray(contentItems)) {
|
||||
const pieces: string[] = [];
|
||||
for (const content of contentItems) {
|
||||
if (!isObject(content)) {
|
||||
continue;
|
||||
}
|
||||
if (content.type !== "text") {
|
||||
continue;
|
||||
}
|
||||
const text = getString(content, "text", "content");
|
||||
if (text) {
|
||||
pieces.push(text);
|
||||
}
|
||||
}
|
||||
return pieces.join(" ").trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractPromptFromInterrupt(interrupt: Interrupt, payload?: RequestInfoPayload): string {
|
||||
const interruptValue = interrupt.value;
|
||||
if (!isObject(interruptValue)) {
|
||||
return "Provide the requested information to continue.";
|
||||
}
|
||||
|
||||
const directPrompt = getString(interruptValue, "message", "prompt");
|
||||
if (directPrompt && directPrompt.length > 0) {
|
||||
return directPrompt;
|
||||
}
|
||||
|
||||
if (payload && isObject(payload.data)) {
|
||||
const agentResponse = getObject(payload.data, "agent_response", "agentResponse");
|
||||
if (agentResponse && Array.isArray(agentResponse.messages)) {
|
||||
const texts = agentResponse.messages
|
||||
.map((message) => extractTextFromMessagePayload(message))
|
||||
.filter((text) => text.length > 0);
|
||||
if (texts.length > 0) {
|
||||
return texts.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const interruptAgentResponse = getObject(interruptValue, "agent_response", "agentResponse");
|
||||
if (interruptAgentResponse && Array.isArray(interruptAgentResponse.messages)) {
|
||||
const texts = interruptAgentResponse.messages
|
||||
.map((message) => extractTextFromMessagePayload(message))
|
||||
.filter((text) => text.length > 0);
|
||||
if (texts.length > 0) {
|
||||
return texts.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
return "Provide the requested information to continue.";
|
||||
}
|
||||
|
||||
function extractFunctionCallFromInterrupt(interrupt: Interrupt): Record<string, unknown> | null {
|
||||
if (!isObject(interrupt.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeCall = getObject(interrupt.value, "function_call", "functionCall");
|
||||
if (isObject(maybeCall)) {
|
||||
return maybeCall;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFunctionArguments(functionCall: Record<string, unknown> | null): Record<string, unknown> {
|
||||
if (!functionCall) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawArguments = functionCall.arguments;
|
||||
if (isObject(rawArguments)) {
|
||||
return rawArguments;
|
||||
}
|
||||
if (typeof rawArguments === "string") {
|
||||
const parsed = safeParseJson(rawArguments);
|
||||
if (isObject(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function interruptKind(interrupt: Interrupt): "approval" | "handoff_input" | "unknown" {
|
||||
if (isObject(interrupt.value) && getString(interrupt.value, "type") === "function_approval_request") {
|
||||
return "approval";
|
||||
}
|
||||
if (isObject(interrupt.value) && getObject(interrupt.value, "agent_response", "agentResponse")) {
|
||||
return "handoff_input";
|
||||
}
|
||||
if (isObject(interrupt.value) && getString(interrupt.value, "message", "prompt")) {
|
||||
return "handoff_input";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): "assistant" | "user" | "system" {
|
||||
if (role === "user" || role === "assistant" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
function normalizeTextForDedupe(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeShippingPreference(text: string): string | null {
|
||||
const normalized = text.trim().toLowerCase();
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/\bstandard\b/.test(normalized)) {
|
||||
return "standard";
|
||||
}
|
||||
|
||||
if (/\b(expedited|express|overnight|priority|next[-\s]?day)\b/.test(normalized)) {
|
||||
return "expedited";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUsagePayload(value: unknown, runId: string | null): UsageDiagnostics | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
runId: runId ?? "unknown",
|
||||
inputTokenCount: getFiniteNumber(value.input_token_count),
|
||||
outputTokenCount: getFiniteNumber(value.output_token_count),
|
||||
totalTokenCount: getFiniteNumber(value.total_token_count),
|
||||
recordedAt: Date.now(),
|
||||
raw: value,
|
||||
};
|
||||
}
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:8891";
|
||||
const endpoint = `${backendUrl.replace(/\/$/, "")}/handoff_demo`;
|
||||
|
||||
const threadIdRef = useRef<string>(randomId());
|
||||
const assistantMessageIndexRef = useRef<Record<string, number>>({});
|
||||
const activeRunIdRef = useRef<string | null>(null);
|
||||
const pendingUsageRef = useRef<UsageDiagnostics | null>(null);
|
||||
|
||||
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
||||
const [requestInfoById, setRequestInfoById] = useState<Record<string, RequestInfoPayload>>({});
|
||||
const [pendingInterrupts, setPendingInterrupts] = useState<Interrupt[]>([]);
|
||||
const [activeAgent, setActiveAgent] = useState<AgentId>("triage_agent");
|
||||
const [visitedAgents, setVisitedAgents] = useState<Set<AgentId>>(new Set(["triage_agent"]));
|
||||
const [caseSnapshot, setCaseSnapshot] = useState<CaseSnapshot>({
|
||||
orderId: "Not captured",
|
||||
refundAmount: "Not captured",
|
||||
refundApproved: "pending",
|
||||
shippingPreference: "Not selected",
|
||||
});
|
||||
const [statusText, setStatusText] = useState<string>("Ready");
|
||||
const [isRunning, setIsRunning] = useState<boolean>(false);
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
const [isApprovalModalOpen, setIsApprovalModalOpen] = useState<boolean>(false);
|
||||
const [latestUsage, setLatestUsage] = useState<UsageDiagnostics | null>(null);
|
||||
const [usageHistory, setUsageHistory] = useState<UsageDiagnostics[]>([]);
|
||||
|
||||
const currentInterrupt = pendingInterrupts[0];
|
||||
const currentInterruptKind = currentInterrupt ? interruptKind(currentInterrupt) : "unknown";
|
||||
const currentRequestInfo = currentInterrupt ? requestInfoById[currentInterrupt.id] : undefined;
|
||||
const interruptPrompt = currentInterrupt
|
||||
? extractPromptFromInterrupt(currentInterrupt, currentRequestInfo)
|
||||
: "No pending interrupt.";
|
||||
|
||||
const functionCall = currentInterrupt ? extractFunctionCallFromInterrupt(currentInterrupt) : null;
|
||||
const functionArguments = useMemo(() => parseFunctionArguments(functionCall), [functionCall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentInterruptKind === "approval") {
|
||||
setIsApprovalModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsApprovalModalOpen(false);
|
||||
}, [currentInterruptKind, currentInterrupt?.id]);
|
||||
|
||||
const pushMessage = (message: DisplayMessage): void => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
};
|
||||
|
||||
const rebuildAssistantMessageIndex = (items: DisplayMessage[]): void => {
|
||||
const next: Record<string, number> = {};
|
||||
items.forEach((item, index) => {
|
||||
if (item.role === "assistant") {
|
||||
next[item.id] = index;
|
||||
}
|
||||
});
|
||||
assistantMessageIndexRef.current = next;
|
||||
};
|
||||
|
||||
const upsertAssistantStart = (messageId: string, role: unknown): void => {
|
||||
const normalizedRole = normalizeRole(role);
|
||||
if (normalizedRole === "user") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
const existingIndex = prev.findIndex((item) => item.id === messageId);
|
||||
if (existingIndex >= 0) {
|
||||
return prev;
|
||||
}
|
||||
const next: DisplayMessage[] = [...prev, { id: messageId, role: normalizedRole, text: "" }];
|
||||
rebuildAssistantMessageIndex(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const appendAssistantDelta = (messageId: string, delta: string): void => {
|
||||
setMessages((prev) => {
|
||||
const index = assistantMessageIndexRef.current[messageId];
|
||||
if (index === undefined) {
|
||||
const next: DisplayMessage[] = [...prev, { id: messageId, role: "assistant", text: delta }];
|
||||
rebuildAssistantMessageIndex(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
const next = [...prev];
|
||||
const existing = next[index];
|
||||
const existingCanonical = normalizeTextForDedupe(existing.text);
|
||||
const deltaCanonical = normalizeTextForDedupe(delta);
|
||||
if (
|
||||
existingCanonical.length >= 24 &&
|
||||
deltaCanonical.length >= 24 &&
|
||||
existingCanonical === deltaCanonical
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
next[index] = { ...existing, text: `${existing.text}${delta}` };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const finalizeAssistantMessage = (messageId: string): void => {
|
||||
setMessages((prev) => {
|
||||
const index = assistantMessageIndexRef.current[messageId];
|
||||
if (index === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const candidate = prev[index];
|
||||
if (candidate.role === "user" || candidate.text.trim().length > 0) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.filter((item) => item.id !== messageId);
|
||||
rebuildAssistantMessageIndex(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateCaseFromApprovalRequest = (payload: RequestInfoPayload): void => {
|
||||
if (!isObject(payload.data) || getString(payload.data, "type") !== "function_approval_request") {
|
||||
return;
|
||||
}
|
||||
const functionCallPayload = getObject(payload.data, "function_call", "functionCall") ?? null;
|
||||
const functionName = functionCallPayload ? getString(functionCallPayload, "name") : undefined;
|
||||
const args = parseFunctionArguments(functionCallPayload);
|
||||
const replacementShippingPreference = getString(args, "shipping_preference", "shippingPreference");
|
||||
|
||||
setCaseSnapshot((prev) => ({
|
||||
...prev,
|
||||
orderId: getString(args, "order_id", "orderId") ?? prev.orderId,
|
||||
refundAmount: getString(args, "amount") ?? prev.refundAmount,
|
||||
shippingPreference: replacementShippingPreference ?? prev.shippingPreference,
|
||||
refundApproved: functionName === "submit_refund" ? "pending" : prev.refundApproved,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateActiveAgent = (candidate: unknown): void => {
|
||||
if (candidate !== "triage_agent" && candidate !== "refund_agent" && candidate !== "order_agent") {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveAgent(candidate);
|
||||
setVisitedAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(candidate);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvent = (event: AgUiEvent): void => {
|
||||
switch (event.type) {
|
||||
case "RUN_STARTED":
|
||||
if (isObject(event)) {
|
||||
const runId = getString(event, "run_id", "runId");
|
||||
if (runId) {
|
||||
activeRunIdRef.current = runId;
|
||||
}
|
||||
}
|
||||
setStatusText("Run started");
|
||||
break;
|
||||
case "STEP_STARTED":
|
||||
if (isObject(event)) {
|
||||
const stepName = getString(event, "step_name", "stepName", "name");
|
||||
if (stepName) {
|
||||
updateActiveAgent(stepName);
|
||||
setStatusText(`Running ${stepName}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "TEXT_MESSAGE_START":
|
||||
if (isObject(event)) {
|
||||
const messageId = getString(event, "message_id", "messageId");
|
||||
if (messageId) {
|
||||
upsertAssistantStart(messageId, event.role);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "TEXT_MESSAGE_CONTENT":
|
||||
if (isObject(event)) {
|
||||
const messageId = getString(event, "message_id", "messageId");
|
||||
const delta = getString(event, "delta");
|
||||
if (messageId && delta) {
|
||||
appendAssistantDelta(messageId, delta);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "TEXT_MESSAGE_END":
|
||||
if (isObject(event)) {
|
||||
const messageId = getString(event, "message_id", "messageId");
|
||||
if (messageId) {
|
||||
finalizeAssistantMessage(messageId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "MESSAGES_SNAPSHOT":
|
||||
// Intentionally ignored for chat rendering in this demo.
|
||||
// AG-UI snapshots can contain full conversation history and cause replay duplication.
|
||||
break;
|
||||
case "TOOL_CALL_ARGS": {
|
||||
if (!isObject(event)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCallId = getString(event, "tool_call_id", "toolCallId");
|
||||
const deltaRaw = getValue(event, "delta");
|
||||
if (!toolCallId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parsed =
|
||||
typeof deltaRaw === "string"
|
||||
? safeParseJson(deltaRaw)
|
||||
: isObject(deltaRaw)
|
||||
? deltaRaw
|
||||
: null;
|
||||
if (!isObject(parsed)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const payload: RequestInfoPayload = {
|
||||
request_id: getString(parsed, "request_id", "requestId"),
|
||||
source_executor_id: getString(parsed, "source_executor_id", "sourceExecutorId"),
|
||||
request_type: getString(parsed, "request_type", "requestType"),
|
||||
response_type: getString(parsed, "response_type", "responseType"),
|
||||
data: getValue(parsed, "data"),
|
||||
};
|
||||
|
||||
setRequestInfoById((prev) => ({
|
||||
...prev,
|
||||
[toolCallId]: payload,
|
||||
}));
|
||||
|
||||
updateCaseFromApprovalRequest(payload);
|
||||
updateActiveAgent(payload.source_executor_id);
|
||||
break;
|
||||
}
|
||||
case "TOOL_CALL_RESULT":
|
||||
if (isObject(event)) {
|
||||
const rawContent = getValue(event, "content");
|
||||
const parsed =
|
||||
typeof rawContent === "string"
|
||||
? safeParseJson(rawContent)
|
||||
: isObject(rawContent)
|
||||
? rawContent
|
||||
: null;
|
||||
if (isObject(parsed)) {
|
||||
updateActiveAgent(getString(parsed, "handoff_to", "handoffTo"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "CUSTOM":
|
||||
if (isObject(event) && getString(event, "name") === "usage") {
|
||||
const usage = normalizeUsagePayload(getValue(event, "value"), activeRunIdRef.current);
|
||||
if (usage) {
|
||||
pendingUsageRef.current = usage;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "RUN_ERROR":
|
||||
setMessages((prev) => {
|
||||
const text = `Run error: ${isObject(event) ? (getString(event, "message") ?? "Unknown error") : "Unknown error"}`;
|
||||
if (prev.length > 0 && prev[prev.length - 1]?.role === "system" && prev[prev.length - 1]?.text === text) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { id: randomId(), role: "system", text }];
|
||||
});
|
||||
setStatusText("Run failed");
|
||||
setIsRunning(false);
|
||||
pendingUsageRef.current = null;
|
||||
break;
|
||||
case "RUN_FINISHED": {
|
||||
const usage = pendingUsageRef.current;
|
||||
if (usage) {
|
||||
setLatestUsage(usage);
|
||||
setUsageHistory((prev) => [usage, ...prev].slice(0, 6));
|
||||
pendingUsageRef.current = null;
|
||||
}
|
||||
|
||||
const rawInterrupts = isObject(event) ? getValue(event, "interrupt", "interrupts") : undefined;
|
||||
const interruptPayload = Array.isArray(rawInterrupts)
|
||||
? rawInterrupts
|
||||
.filter((item): item is Record<string, unknown> => isObject(item))
|
||||
.map((item) => ({
|
||||
id: String(item.id ?? ""),
|
||||
value: item.value,
|
||||
}))
|
||||
.filter((item) => item.id.length > 0)
|
||||
: [];
|
||||
|
||||
for (const interrupt of interruptPayload) {
|
||||
if (!isObject(interrupt.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updateCaseFromApprovalRequest({ data: interrupt.value });
|
||||
|
||||
const sourceExecutor = getString(interrupt.value, "source_executor_id", "sourceExecutorId");
|
||||
if (sourceExecutor) {
|
||||
updateActiveAgent(sourceExecutor);
|
||||
}
|
||||
|
||||
const agentResponse = getObject(interrupt.value, "agent_response", "agentResponse");
|
||||
if (agentResponse && Array.isArray(agentResponse.messages)) {
|
||||
const lastMessage = [...agentResponse.messages].reverse().find(isObject);
|
||||
if (lastMessage) {
|
||||
updateActiveAgent(getString(lastMessage, "author_name", "authorName"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPendingInterrupts(interruptPayload);
|
||||
setStatusText(interruptPayload.length > 0 ? "Waiting for input" : "Run complete");
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const streamRun = async (body: Record<string, unknown>): Promise<void> => {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
const processSseChunk = (rawChunk: string): void => {
|
||||
const dataLines = rawChunk
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trim());
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = dataLines.join("\n");
|
||||
const parsed = safeParseJson(payload);
|
||||
if (isObject(parsed) && typeof parsed.type === "string") {
|
||||
handleEvent(parsed as AgUiEvent);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (true) {
|
||||
const boundaryIndex = buffer.indexOf("\n\n");
|
||||
if (boundaryIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const rawEvent = buffer.slice(0, boundaryIndex);
|
||||
buffer = buffer.slice(boundaryIndex + 2);
|
||||
processSseChunk(rawEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buffer.trim();
|
||||
if (tail.length > 0) {
|
||||
processSseChunk(tail);
|
||||
}
|
||||
};
|
||||
|
||||
const runWithPayload = async (payload: Record<string, unknown>): Promise<void> => {
|
||||
activeRunIdRef.current = typeof payload.run_id === "string" ? payload.run_id : null;
|
||||
pendingUsageRef.current = null;
|
||||
setIsRunning(true);
|
||||
setStatusText("Connecting");
|
||||
|
||||
try {
|
||||
await streamRun(payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
pushMessage({ id: randomId(), role: "system", text: `Network error: ${message}` });
|
||||
setStatusText("Network error");
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewTurn = async (text: string): Promise<void> => {
|
||||
pushMessage({ id: randomId(), role: "user", text });
|
||||
|
||||
await runWithPayload({
|
||||
thread_id: threadIdRef.current,
|
||||
run_id: randomId(),
|
||||
messages: [{ role: "user", content: text }],
|
||||
});
|
||||
};
|
||||
|
||||
const resumeApproval = async (approved: boolean): Promise<void> => {
|
||||
if (!currentInterrupt || !functionCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionName = getString(functionCall, "name") ?? "tool_call";
|
||||
|
||||
if (functionName === "submit_refund") {
|
||||
setCaseSnapshot((prev) => ({
|
||||
...prev,
|
||||
refundApproved: approved ? "approved" : "rejected",
|
||||
}));
|
||||
}
|
||||
|
||||
setIsApprovalModalOpen(false);
|
||||
|
||||
pushMessage({
|
||||
id: randomId(),
|
||||
role: "system",
|
||||
text: approved ? `HITL Reviewer approved ${functionName}.` : `HITL Reviewer rejected ${functionName}.`,
|
||||
});
|
||||
|
||||
const approvalResponse = {
|
||||
type: "function_approval_response",
|
||||
approved,
|
||||
id: String((isObject(currentInterrupt.value) && currentInterrupt.value.id) || currentInterrupt.id),
|
||||
function_call: functionCall,
|
||||
};
|
||||
|
||||
await runWithPayload({
|
||||
thread_id: threadIdRef.current,
|
||||
run_id: randomId(),
|
||||
messages: [],
|
||||
resume: {
|
||||
interrupts: [
|
||||
{
|
||||
id: currentInterrupt.id,
|
||||
value: approvalResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resumeHandoffInput = async (text: string): Promise<void> => {
|
||||
if (!currentInterrupt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromOrderAgent = currentRequestInfo?.source_executor_id === "order_agent";
|
||||
const shippingPreference = fromOrderAgent ? normalizeShippingPreference(text) : null;
|
||||
if (shippingPreference) {
|
||||
setCaseSnapshot((prev) => ({
|
||||
...prev,
|
||||
shippingPreference,
|
||||
}));
|
||||
}
|
||||
|
||||
pushMessage({ id: randomId(), role: "user", text });
|
||||
|
||||
await runWithPayload({
|
||||
thread_id: threadIdRef.current,
|
||||
run_id: randomId(),
|
||||
messages: [],
|
||||
resume: {
|
||||
interrupts: [
|
||||
{
|
||||
id: currentInterrupt.id,
|
||||
value: [
|
||||
{
|
||||
role: "user",
|
||||
contents: [{ type: "text", text }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const trimmed = inputText.trim();
|
||||
if (!trimmed || isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInputText("");
|
||||
|
||||
if (currentInterruptKind === "approval") {
|
||||
setIsApprovalModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentInterruptKind === "handoff_input") {
|
||||
await resumeHandoffInput(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
await startNewTurn(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">AG-UI Workflow Demo</p>
|
||||
<h1>Handoff + Tool Approval</h1>
|
||||
<p className="subtitle">
|
||||
Dynamic workflow exercising AG-UI run events, interrupt resumes, function approvals, and stateful
|
||||
per-thread execution.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-pill" data-running={isRunning}>
|
||||
<span>Status</span>
|
||||
<strong>{statusText}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="layout">
|
||||
<section className="dashboard-panel">
|
||||
<article className="card snapshot-card">
|
||||
<h2>Case Snapshot</h2>
|
||||
<div className="snapshot-grid">
|
||||
<div>
|
||||
<span>Order ID</span>
|
||||
<strong>{caseSnapshot.orderId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Refund Amount</span>
|
||||
<strong>{caseSnapshot.refundAmount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Refund Approval</span>
|
||||
<strong data-state={caseSnapshot.refundApproved}>{caseSnapshot.refundApproved}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Shipping Preference</span>
|
||||
<strong>{caseSnapshot.shippingPreference}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card agents-card">
|
||||
<h2>Active Agent</h2>
|
||||
<div className="agent-pills">
|
||||
{KNOWN_AGENTS.map((agent) => (
|
||||
<button
|
||||
key={agent}
|
||||
type="button"
|
||||
className="agent-pill"
|
||||
data-active={agent === activeAgent}
|
||||
data-seen={visitedAgents.has(agent)}
|
||||
disabled
|
||||
>
|
||||
{AGENT_LABELS[agent]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card diagnostics-card">
|
||||
<h2>Diagnostics</h2>
|
||||
{!latestUsage && <p className="muted">Usage appears when the final streaming chunk arrives.</p>}
|
||||
|
||||
{latestUsage && (
|
||||
<div className="diagnostics-body">
|
||||
<div className="diagnostics-grid">
|
||||
<div>
|
||||
<span>Run ID</span>
|
||||
<strong>{latestUsage.runId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Input Tokens</span>
|
||||
<strong>{latestUsage.inputTokenCount ?? "n/a"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Output Tokens</span>
|
||||
<strong>{latestUsage.outputTokenCount ?? "n/a"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Total Tokens</span>
|
||||
<strong>{latestUsage.totalTokenCount ?? "n/a"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="muted diagnostics-timestamp">
|
||||
Last updated {new Date(latestUsage.recordedAt).toLocaleTimeString()}
|
||||
</p>
|
||||
|
||||
<details className="diagnostics-raw">
|
||||
<summary>Raw usage payload</summary>
|
||||
<pre>{JSON.stringify(latestUsage.raw, null, 2)}</pre>
|
||||
</details>
|
||||
|
||||
{usageHistory.length > 1 && (
|
||||
<div className="diagnostics-history">
|
||||
<h3>Recent runs</h3>
|
||||
{usageHistory.map((entry, index) => (
|
||||
<div key={`${entry.runId}-${entry.recordedAt}-${index}`} className="diagnostics-history-item">
|
||||
<span>{entry.runId}</span>
|
||||
<strong>{entry.totalTokenCount ?? "n/a"} total</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="card interrupt-card">
|
||||
<h2>Pending Action</h2>
|
||||
{!currentInterrupt && <p className="muted">No interrupt pending. Start with one of the prompts below.</p>}
|
||||
|
||||
{currentInterrupt && (
|
||||
<div className="interrupt-body">
|
||||
<p>{interruptPrompt}</p>
|
||||
|
||||
{currentInterruptKind === "approval" && (
|
||||
<div className="approval-inline">
|
||||
<p className="muted">
|
||||
Customer input is paused. A separate reviewer must approve or reject this tool call.
|
||||
</p>
|
||||
<div className="approval-details">
|
||||
<p>
|
||||
<strong>Function:</strong> {String(functionCall?.name ?? "tool_call")}
|
||||
</p>
|
||||
<pre>{JSON.stringify(functionArguments, null, 2)}</pre>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="approval-launch"
|
||||
onClick={() => setIsApprovalModalOpen(true)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Open Reviewer Modal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInterruptKind === "handoff_input" && (
|
||||
<p className="muted">Reply in the chat input to resume this request.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!currentInterrupt && (
|
||||
<div className="starter-prompts">
|
||||
{STARTER_PROMPTS.map((prompt) => (
|
||||
<button key={prompt} type="button" onClick={() => void startNewTurn(prompt)} disabled={isRunning}>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="chat-panel">
|
||||
<div className="chat-scroll">
|
||||
{messages.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Send a message to start the handoff workflow.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<article key={message.id} className="chat-bubble" data-role={message.role}>
|
||||
<header>{message.role}</header>
|
||||
<p>{message.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="chat-input" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<input
|
||||
value={inputText}
|
||||
onChange={(event) => setInputText(event.target.value)}
|
||||
placeholder={
|
||||
currentInterruptKind === "approval"
|
||||
? "Waiting for reviewer approval..."
|
||||
: currentInterruptKind === "handoff_input"
|
||||
? "Reply to continue..."
|
||||
: "Describe your issue..."
|
||||
}
|
||||
disabled={isRunning || currentInterruptKind === "approval"}
|
||||
/>
|
||||
<button type="submit" disabled={isRunning || currentInterruptKind === "approval" || inputText.trim().length === 0}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{currentInterruptKind === "approval" && currentInterrupt && isApprovalModalOpen && (
|
||||
<div className="approval-modal-backdrop" onClick={() => setIsApprovalModalOpen(false)}>
|
||||
<section className="approval-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="approval-modal-header">
|
||||
<div>
|
||||
<p className="approval-modal-label">HITL Reviewer Console</p>
|
||||
<h3>Tool Approval Required</h3>
|
||||
</div>
|
||||
<button type="button" className="approval-modal-close" onClick={() => setIsApprovalModalOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p className="muted">{interruptPrompt}</p>
|
||||
|
||||
<div className="approval-details">
|
||||
<p>
|
||||
<strong>Function:</strong> {String(functionCall?.name ?? "tool_call")}
|
||||
</p>
|
||||
<pre>{JSON.stringify(functionArguments, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div className="approval-actions">
|
||||
<button type="button" className="defer" onClick={() => setIsApprovalModalOpen(false)} disabled={isRunning}>
|
||||
Defer
|
||||
</button>
|
||||
<button type="button" className="reject" onClick={() => void resumeApproval(false)} disabled={isRunning}>
|
||||
Reject Tool Call
|
||||
</button>
|
||||
<button type="button" className="approve" onClick={() => void resumeApproval(true)} disabled={isRunning}>
|
||||
Approve Tool Call
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,544 @@
|
||||
/* 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,
|
||||
.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,
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
@@ -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