feat: useChatWorkflow (#140)

---------
Co-authored-by: leehuwuj <leehuwuj@gmail.com>
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
Thuc Pham
2025-06-25 14:22:16 +07:00
committed by GitHub
parent f84b7f7e20
commit b15d152139
55 changed files with 2028 additions and 98 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@llamaindex/chat-ui': patch
---
feat: useChatWorkflow
+197 -21
View File
@@ -405,27 +405,30 @@ function SafeHookUsage() {
### useWorkflow
Manage LlamaIndex workflow sessions, send events, and handle streaming responses.
Manages LlamaIndex workflows that are deployed on [LlamaDeploy](https://github.com/run-llama/llama_deploy). Start new instances, send events, and handle streamed event responses. Key features:
- **Client Management**: Automatically creates and manages the LlamaDeploy client connection
- **Run Lifecycle**: Creates and manages workflow runs
- **Event Streaming**: Real-time streaming of workflow events
- **State Management**: Tracks workflow status (running, complete, error) and event history
- **Error Handling**: Provides callbacks for handling errors and stop events
To get started, you need a deployment with a workflow running. You can create a deployment with a workflow by following the [LlamaDeploy documentation](https://docs.llamaindex.ai/en/stable/module_guides/llama_deploy/).
To create the hook, you'll pass the `baseUrl` of the LlamaDeploy server, the `deployment` and `workflow` name to the `useWorkflow` hook. Here's an example file:
```tsx
import { useWorkflow } from '@llamaindex/chat-ui'
function WorkflowComponent() {
const {
runId,
start,
stop,
sendEvent,
events,
status,
} = useWorkflow({
const { runId, start, stop, sendEvent, events, status } = useWorkflow({
baseUrl: 'http://localhost:8000',
deployment: 'my-deployment',
workflow: 'my-workflow',
onStopEvent: (event) => {
onStopEvent: event => {
console.log('Workflow stopped:', event)
},
onError: (error) => {
onError: error => {
console.error('Workflow error:', error)
},
})
@@ -437,7 +440,7 @@ function WorkflowComponent() {
const handleSendCustomEvent = async () => {
await sendEvent({
type: 'workflow.ProgressEvent',
data: { custom: 'data' }
data: { custom: 'data' },
})
}
@@ -478,7 +481,7 @@ function WorkflowComponent() {
- `baseUrl` - Base URL for the workflow API (optional)
- `deployment` - Name of the registered deployment
- `workflow` - Set the service to run - if not set uses the default workflow (optional)
- `workflow` - Set the workflow to run - if not set uses the default workflow (optional)
- `runId` - Optional run ID for resuming a previous workflow run
- `onStopEvent` - Callback when workflow receives a stop event
- `onError` - Error callback for workflow errors
@@ -486,20 +489,28 @@ function WorkflowComponent() {
**Returned Properties:**
- `runId` - Current workflow run ID
- `start` - Function to start a new workflow with initial event data
- `stop` - Function to send stop event to current workflow
- `sendEvent` - Function to send custom events to the workflow
- `events` - Array of all received workflow events
- `sendEvent` - Function to send events of type `WorkflowEvent` to the workflow. Just pass the event object with the `type` and `data` fields as parameter.
- `start` - Function to start a new workflow. This is sending a `StartEvent` besides starting the workflow run, so you pass the `data` object for the `StartEvent` as parameter.
- `stop` - Function to send stop event to current workflow. This is sending a `StopEvent` besides stopping the workflow run, so you pass the `data` object for the `StopEvent` as parameter.
- `events` - Array of all received workflow events of type `WorkflowEvent`
- `status` - Current workflow run status ('idle' | 'running' | 'complete' | 'error')
**Workflow Event Types:**
A workflow event is identified by its `type` and can have optional `data` associated with it carrying any JSON-serializable data:
```tsx
interface WorkflowEvent {
type: string
data?: any
data?: JSONValue | undefined
}
```
The `type` field is the fully qualified name of the event type in Python.
There are a few built-in event types that you can use with the `type` field:
```tsx
// Built-in event types
enum WorkflowEventType {
StartEvent = 'llama_index.core.workflow.events.StartEvent',
@@ -516,7 +527,7 @@ function ResumeWorkflowComponent() {
const workflow = useWorkflow({
deployment: 'my-deployment',
runId: 'existing-run-id', // Resume existing task
onStopEvent: (event) => {
onStopEvent: event => {
console.log('Workflow completed:', event)
},
})
@@ -551,7 +562,7 @@ function CustomWorkflowComponent() {
const workflow = useWorkflow<CustomWorkflowEvent>({
deployment: 'progress-deployment',
workflow: 'progress-workflow',
onStopEvent: (event) => {
onStopEvent: event => {
if (event.type === 'workflow.ResultEvent') {
console.log('Final result:', event.data.result)
}
@@ -561,7 +572,8 @@ function CustomWorkflowComponent() {
const progressEvents = workflow.events.filter(
e => e.type === 'workflow.ProgressEvent'
)
const latestProgress = progressEvents[progressEvents.length - 1]?.data?.progress || 0
const latestProgress =
progressEvents[progressEvents.length - 1]?.data?.progress || 0
return (
<div>
@@ -571,3 +583,167 @@ function CustomWorkflowComponent() {
)
}
```
### useChatWorkflow
The `useChatWorkflow` hook is a specialized version extending the `useWorkflow` designed specifically for chat interfaces. It is used for building chat interfaces for workflows running on [LlamaDeploy](https://github.com/run-llama/llama_deploy).
It returns a `ChatHandler`, so that it works seamlessly with the chat-ui chat components. Therefore, it is also a drop-in replacement for the [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) hook from Vercel AI.
Here's an example of how to use it:
```tsx
import {
ChatSection,
ChatMessages,
ChatMessage,
ChatInput,
useChatWorkflow,
} from '@llamaindex/chat-ui'
function WorkflowChatApp() {
const handler = useChatWorkflow({
baseUrl: 'http://localhost:4501',
deployment: 'my-deployment',
workflow: 'chat-workflow',
onError: error => console.error(error),
})
return (
<ChatSection handler={handler}>
<ChatMessages>
<ChatMessages.List>
{handler.messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
isLast={index === handler.messages.length - 1}
>
<ChatMessage.Avatar />
<ChatMessage.Content>
<ChatMessage.Content.Markdown />
<ChatMessage.Content.Source />
{/* Custom annotations for UIEvents */}
<WeatherAnnotation />
</ChatMessage.Content>
<ChatMessage.Actions />
</ChatMessage>
))}
</ChatMessages.List>
</ChatMessages>
<ChatInput />
</ChatSection>
)
}
```
**Parameters:**
- `baseUrl` - Base URL for the workflow API (optional)
- `deployment` - Name of the registered deployment
- `workflow` - Set the workflow to run - if not set uses the default workflow (optional)
- `onError` - Error callback for workflow errors
#### Chat Workflows
To be used as a chat workflow, your workflows need to follow the same calling convention as a [Agent Workflow](https://docs.llamaindex.ai/en/stable/understanding/agent) - that's why Agent Workflows are working out of the box with the `useChatWorkflow` hook.
Your workflow will be executed once for each chat request with the following input parameters included in workflow's `StartEvent`:
- `user_msg` [str]: The current user message
- `chat_history` [list[[ChatMessage](https://docs.llamaindex.ai/en/stable/api_reference/prompts/#llama_index.core.prompts.ChatMessage)]]: All the previous messages of the conversation
Example:
```python
@step
def handle_start_event(ev: StartEvent) -> MyNextEvent:
user_msg = ev.user_msg
chat_history = ev.chat_history
...
```
#### Built-in Workflow Events
The hook automatically processes workflow events (using `useWorkflow`) and renders them as annotations in the chat interface, making it easy to build rich conversational experiences with LlamaDeploy workflows.
Your LlamaDeploy workflows can send three main types of events to enhance the chat experience:
##### 1. SourceNodesEvent - Citations and References
Send source nodes to display citations and references for generated content:
```python
from llama_index.server.models import SourceNodesEvent
from llama_index.core.schema import NodeWithScore
from llama_index.core.data_structs import Node
ctx.write_event_to_stream(
SourceNodesEvent(
nodes=[
NodeWithScore(
node=Node(
text="sample node content",
metadata={"URL": "https://example.com/document.pdf"},
),
score=0.8,
),
],
)
)
```
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Content.Source` component as a child to display the citations and references (as shown in the example above).
##### 2. ArtifactEvent - Code and Artifacts
Send code snippets, documents, or other artifacts that can be displayed in a dedicated canvas:
```python
from llama_index.server.models import ArtifactEvent
import time
ctx.write_event_to_stream(
ArtifactEvent(
data={
"type": "code",
"created_at": int(time.time()),
"data": {
"language": "typescript",
"file_name": "example.ts",
"code": 'console.log("Hello, world!");',
},
}
)
)
```
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Content.Markdown` component as a child to display the artifacts inline in the markdown component (as shown in the example above).
##### 3. UIEvent - Custom UI Components
Send custom data to render it in a specialized UI components. In your workflow code, you can send `UIEvent`s for this - for example to render a weather annotation in the chat interface:
```python
from llama_index.server.models import UIEvent
from pydantic import BaseModel
class WeatherData(BaseModel):
location: str
temperature: float
condition: str
humidity: int
windSpeed: int
weather_data = WeatherData(
location="San Francisco, CA",
temperature=22,
condition="sunny",
humidity=65,
windSpeed=12,
)
ctx.write_event_to_stream(
UIEvent(type="weather", data=weather_data)
)
```
To render this custom UI component, you need to add it as child to your `ChatMessage.Content` component. The example above will render the [`WeatherAnnotation`](../../examples/llamadeploy-chat/ui/components/custom/custom-weather.tsx) component in the chat interface.
-63
View File
@@ -1,63 +0,0 @@
# LlamaDeploy + Chat-UI Example
This example demonstrates how to use the **chat-ui** library to create a custom interface for workflows deployed with [LlamaDeploy](https://github.com/run-llama/llama_deploy).
LlamaDeploy is a system for deploying and managing LlamaIndex workflows as microservices. This example shows how you can build a React-based chat interface that connects to and interacts with your deployed workflows using the `useWorkflow` hook.
## useWorkflow Hook
The `useWorkflow` hook is a React hook provided by chat-ui that simplifies interaction with LlamaDeploy workflows. It handles:
- **Client Management**: Automatically creates and manages the LlamaDeploy client connection
- **Task Lifecycle**: Creates new tasks, manages existing tasks, and handles task sessions
- **Event Streaming**: Real-time streaming of workflow events and responses
- **State Management**: Tracks workflow status (running, complete, error) and event history
- **Error Handling**: Provides callbacks for handling errors and stop events
### Key Features:
- Start new workflow tasks with custom event data
- Send events to running workflows
- Stream real-time events from workflows
- Manage workflow state and session persistence
- Handle workflow completion and error states
## Installation
Both the SDK and the CLI are part of the LlamaDeploy Python package. To install, just run:
```bash
pip install -U llama-deploy
```
## Running the Deployment
At this point we have all we need to run this deployment. Ideally, we would have the API server already running
somewhere in the cloud, but to get started let's start an instance locally. Run the following python script
from a shell:
```
$ python -m llama_deploy.apiserver
INFO: Started server process [10842]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:4501 (Press CTRL+C to quit)
```
From another shell, use the CLI, `llamactl`, to create the deployment:
```
$ llamactl deploy llama_deploy.yml
Deployment successful: QuickStart
```
### UI Interface
LlamaDeploy will serve the UI through the apiserver, at the address `http://localhost:4501/ui/<deployment name>`. In
this case, point the browser to [http://localhost:4501/deployments/QuickStart/ui](http://localhost:4501/deployments/QuickStart/ui) to interact
with your deployment through a user-friendly interface.
## Learn More
- [LlamaDeploy GitHub Repository](https://github.com/run-llama/llama_deploy)
- [Chat-UI Documentation](../../docs/chat-ui/)
+55
View File
@@ -0,0 +1,55 @@
# LlamaDeploy + Chat-UI Example
This example demonstrates how to use the **chat-ui** library to create a custom chat interface for workflows deployed with [LlamaDeploy](https://github.com/run-llama/llama_deploy).
LlamaDeploy is a system for deploying and managing LlamaIndex workflows. This example shows how you can build a React-based chat interface using Chat UI components that connect with your deployed workflow using the [`useChatWorkflow`](../../docs/chat-ui/hooks.mdx#usechatworkflow) hook.
## Key Features
- Multiple examples of workflows:
- [Custom Chat Workflow](src/chat_workflow.py)
- [Agent Workflow](src/agent_workflow.py)
- [Human in the Loop Workflow](src/cli_workflow.py)
Test the workflows by selecting one of them in the UI. The custom chat workflow is most sophisticated, as it supports sending annotations to the chat messages by sending specific events.
## Installation
Both the SDK and the CLI are part of the LlamaDeploy Python package. To install, just run:
```bash
uv sync
```
## Running the Deployment
At this point we have all we need to run this deployment. Ideally, we would have the API server already running
somewhere in the cloud, but to get started let's start an instance locally. Run the following python script
from a shell:
```
$ uv run -m llama_deploy.apiserver
INFO: Started server process [10842]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:4501 (Press CTRL+C to quit)
```
From another shell, use the CLI, `llamactl`, to create the deployment:
```
$ uv run llamactl deploy llama_deploy.yml
Deployment successful: QuickStart
```
### UI Interface
LlamaDeploy will serve the UI through the apiserver. Point the browser to [http://localhost:4501/deployments/QuickStart/ui](http://localhost:4501/deployments/QuickStart/ui) to interact
with your deployment through a user-friendly interface.
## Learn More
- [useChatWorkflow Hook](../../docs/chat-ui/hooks.mdx#usechatworkflow)
- [useWorkflow Hook](../../docs/chat-ui/hooks.mdx#useworkflow)
- [LlamaDeploy GitHub Repository](https://github.com/run-llama/llama_deploy)
- [Chat-UI Documentation](../../docs/chat-ui/)
@@ -0,0 +1,38 @@
name: QuickStart
control-plane:
port: 8000
default-service: chat_workflow
services:
chat_workflow:
name: Chat Workflow
source:
type: local
name: src
path: src/chat_workflow:workflow
python-dependencies:
- llama-index-llms-openai>=0.4.5
- llama-index-server>=0.1.22
agent_workflow:
name: Agent Workflow
source:
type: local
name: src
path: src/agent_workflow:workflow
python-dependencies:
- llama-index-llms-openai>=0.4.5
cli_workflow:
name: CLI Workflow
source:
type: local
name: src
path: src/cli_workflow:workflow
ui:
name: My Nextjs App
port: 3000
source:
type: local
name: ui
@@ -5,5 +5,8 @@ description = "Using LlamaDeploy with Chat UI"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.115.12",
]
"llama-deploy",
]
[tool.uv.sources]
llama-deploy = { git = "https://github.com/run-llama/llama_deploy" }
@@ -0,0 +1,8 @@
from llama_index.llms.openai import OpenAI
from llama_index.core.agent.workflow import AgentWorkflow
workflow = AgentWorkflow.from_tools_or_functions(
tools_or_functions=[],
llm=OpenAI(model="gpt-4o-mini"),
system_prompt="You are a helpful assistant",
)
@@ -0,0 +1,113 @@
from llama_index.core.workflow import (
Workflow,
StartEvent,
StopEvent,
step,
Context,
)
from llama_index.core.schema import (
NodeWithScore,
)
from llama_index.llms.openai import OpenAI
from llama_index.core.agent.workflow.workflow_events import AgentStream
from llama_index.core.llms import ChatMessage
from typing import List, Optional
from llama_index.server.models import (
SourceNodesEvent,
ArtifactEvent,
UIEvent,
)
from llama_index.core.data_structs import Node
from pydantic import BaseModel
import time
class WeatherData(BaseModel):
location: str
temperature: float
condition: str
humidity: int
windSpeed: int
class ChatWorkflow(Workflow):
llm: OpenAI = OpenAI(model="gpt-4.1")
@step()
async def run_step(self, ctx: Context, ev: StartEvent) -> StopEvent:
user_msg: str = ev.get("user_msg")
chat_history: Optional[List[ChatMessage]] = ev.get("chat_history", [])
messages = [*chat_history, ChatMessage(role="user", content=user_msg)]
# check messages length is 0
if len(messages) == 0:
return StopEvent(result="No messages provided")
res = await self.llm.astream_chat(messages=messages)
final_response = ""
async for chunk in res:
ctx.write_event_to_stream(
AgentStream(
delta=chunk.delta or "",
response=final_response,
current_agent_name="assistant",
tool_calls=[],
raw=chunk.delta or "",
)
)
final_response += chunk.delta or ""
# send a sample source nodes event
ctx.write_event_to_stream(
SourceNodesEvent(
nodes=[
NodeWithScore(
node=Node(
text="sample node 1",
metadata={"URL": "https://pdfobject.com/pdf/sample.pdf"},
),
score=0.7,
),
NodeWithScore(
node=Node(
text="sample node 2",
metadata={"URL": "https://pdfobject.com/pdf/sample.pdf"},
),
score=0.8,
),
],
)
)
# send a sample artifact event
ctx.write_event_to_stream(
ArtifactEvent(
data={
"type": "code",
"created_at": int(time.time()),
"data": {
"language": "typescript",
"file_name": "sample-artifact.ts",
"code": 'console.log("Hello, world!");',
},
}
)
)
# send a sample UI event with weather data
weather_data = WeatherData(
location="San Francisco, CA",
temperature=22,
condition="sunny",
humidity=65,
windSpeed=12,
)
ctx.write_event_to_stream(UIEvent(type="weather", data=weather_data))
return StopEvent(result=final_response)
workflow = ChatWorkflow()
@@ -0,0 +1,102 @@
import subprocess
from typing import Any
from pydantic import Field
from llama_index.core.prompts import PromptTemplate
from llama_index.core.settings import Settings
from llama_index.core.workflow import (
Context,
InputRequiredEvent,
HumanResponseEvent,
StartEvent,
StopEvent,
Workflow,
step,
)
from llama_index.core.llms import ChatMessage
from llama_index.core.agent.workflow.workflow_events import AgentStream
class CLIHumanInputEvent(InputRequiredEvent):
command: str = Field(description="The command to execute.")
class CLIHumanResponseEvent(HumanResponseEvent):
execute: bool = Field(description="Whether to execute the command or not.")
command: str = Field(description="The command to execute.")
class CLIWorkflow(Workflow):
default_prompt = PromptTemplate(
template="""
You are a helpful assistant who can write CLI commands to execute using bash.
Your task is to analyze the user's request and write a CLI command to execute.
## User Request
{user_request}
Don't be verbose, only respond with the CLI command without any other text.
"""
)
def __init__(self, **kwargs: Any) -> None:
# HITL Workflow should disable timeout otherwise, we will get a timeout error from callback
kwargs["timeout"] = None
super().__init__(**kwargs)
@step
async def start(self, ctx: Context, ev: StartEvent) -> CLIHumanInputEvent:
user_msg: str = ev.get("user_msg")
prompt = self.default_prompt.format(user_request=user_msg)
response = await Settings.llm.acomplete(prompt, formatted=True)
command = response.text.strip()
if command == "":
raise ValueError("Couldn't generate a command")
return CLIHumanInputEvent(
command=command,
)
@step
async def handle_human_response(
self,
ctx: Context,
ev: CLIHumanResponseEvent,
) -> StopEvent:
should_execute = ev.execute
if should_execute:
res = subprocess.run(ev.command, shell=True, capture_output=True, text=True)
command_result = res.stdout or res.stderr
res = await Settings.llm.astream_chat(
messages=[
ChatMessage(
role="user",
content=f"Show this command result {command_result} and summarize its output.",
),
],
)
final_response = ""
async for chunk in res:
ctx.write_event_to_stream(
AgentStream(
delta=chunk.delta or "",
response=final_response,
current_agent_name="assistant",
tool_calls=[],
raw=chunk.delta or "",
)
)
final_response += chunk.delta or ""
return StopEvent(result=final_response)
else:
return StopEvent(result=None)
workflow = CLIWorkflow()
@@ -55,5 +55,6 @@ module.exports = {
'no-nested-ternary': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
},
}
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
pnpm-lock.yaml

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,185 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@custom-variant dark (&:is(.dark *));
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-xl: calc(var(--radius) + 4px);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans:
var(--font-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-shimmer: shimmer 2s linear infinite;
--background-image-glow-conic:
radial-gradient(at 21% 11%, rgba(186, 186, 233, 0.53) 0, transparent 50%),
radial-gradient(at 85% 0, hsla(46, 57%, 78%, 0.52) 0, transparent 50%),
radial-gradient(at 91% 36%, rgba(194, 213, 255, 0.68) 0, transparent 50%),
radial-gradient(at 8% 40%, rgba(251, 218, 239, 0.46) 0, transparent 50%);
--background-mesh-gradient: conic-gradient(
from 230.29deg at 51.63% 52.16%,
#2400ff 0deg,
#b000ff 67.5deg,
#ff006e 198.75deg,
#ff7d10 251.25deg,
#ffaa44 301.88deg,
#2400ff 360deg
);
--glass-gradient: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
--glass-border: linear-gradient(
135deg,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.1)
);
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,25 @@
import './globals.css'
import '@llamaindex/chat-ui/styles/editor.css'
import '@llamaindex/chat-ui/styles/markdown.css'
import '@llamaindex/chat-ui/styles/pdf.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'LlamaDeploy with useChatWorkflow',
description: 'useChatWorkflow Example for LlamaDeploy',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}): JSX.Element {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
import {
ChatCanvas,
ChatInput,
ChatMessage,
ChatMessages,
ChatSection,
ChatWorkflowResume,
useChatUI,
useChatWorkflow,
} from '@llamaindex/chat-ui'
import { WeatherAnnotation } from '@/components/custom/custom-weather'
import { CLIHumanInput } from '@/components/custom/human-input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useState } from 'react'
import { StarterQuestions } from '@llamaindex/chat-ui/widgets'
const DEPLOYMENT_NAME = 'QuickStart'
const DEFAULT_WORKFLOW = 'chat_workflow'
const chatStarterQuestions = [
'What can you do?',
'Write a poem about the weather',
]
const hitlStarterQuestions = [
'List all files in the current directory',
'Check status of the git repository',
]
export default function Page(): JSX.Element {
const [workflow, setWorkflow] = useState(DEFAULT_WORKFLOW)
const handler = useChatWorkflow({
deployment: DEPLOYMENT_NAME,
workflow,
onError: error => {
console.error(error)
},
})
return (
<div className="relative h-screen">
<div className="absolute left-6 top-6 z-10">
<Select
value={workflow}
onValueChange={setWorkflow}
disabled={handler.isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select workflow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="chat_workflow">Chat Workflow</SelectItem>
<SelectItem value="agent_workflow">Agent Workflow</SelectItem>
<SelectItem value="cli_workflow">CLI HITL Workflow</SelectItem>
</SelectContent>
</Select>
</div>
<ChatSection
handler={handler}
className="block h-screen flex-row gap-4 p-0 md:flex md:p-5"
>
<div className="md:max-w-1/2 mx-auto flex h-full min-w-0 max-w-full flex-1 flex-col gap-4">
<CustomChatMessages
resumeWorkflow={handler.resume}
workflow={workflow}
/>
<ChatInput />
</div>
<ChatCanvas className="w-full md:w-2/3" />
</ChatSection>
</div>
)
}
function CustomChatMessages({
resumeWorkflow,
workflow,
}: {
resumeWorkflow: ChatWorkflowResume
workflow: string
}) {
const { messages, isLoading, append } = useChatUI()
const starterQuestions =
workflow === 'cli_workflow' ? hitlStarterQuestions : chatStarterQuestions
return (
<ChatMessages>
<ChatMessages.List className="px-4 py-6">
{messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
isLast={index === messages.length - 1}
className="mb-4"
>
<ChatMessage.Avatar />
<ChatMessage.Content isLoading={isLoading} append={append}>
<CLIHumanInput resumeWorkflow={resumeWorkflow} />
<ChatMessage.Content.Markdown />
<WeatherAnnotation />
<ChatMessage.Content.Source />
</ChatMessage.Content>
<ChatMessage.Actions />
</ChatMessage>
))}
<ChatMessages.Empty
heading="LlamaDeploy with Chat UI Example"
subheading="Demo using useChatWorkflow hook"
/>
<ChatMessages.Loading />
</ChatMessages.List>
{messages.length === 0 && (
<StarterQuestions questions={starterQuestions} append={append} />
)}
</ChatMessages>
)
}
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,60 @@
'use client'
import { useChatMessage, getAnnotationData } from '@llamaindex/chat-ui'
interface WeatherData {
location: string
temperature: number
condition: string
humidity: number
windSpeed: number
}
// A custom annotation component that is used to display weather information in a chat message
// The weather data is extracted from annotations in the message that has type 'weather'
export function WeatherAnnotation() {
const { message } = useChatMessage()
const weatherData = getAnnotationData<WeatherData>(message, 'weather')
if (weatherData.length === 0) return null
return <WeatherCard data={weatherData[0]} />
}
function WeatherCard({ data }: { data: WeatherData }) {
const iconMap: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
snowy: '❄️',
stormy: '⛈️',
}
if (!data.location) return null
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<span className="text-2xl">{iconMap[data.condition] || '🌤️'}</span>
</div>
<div className="flex-1">
<h3 className="font-semibold text-blue-900">{data.location}</h3>
<div className="flex items-center gap-4 text-sm text-blue-700">
<span className="text-2xl font-bold">{data.temperature}°C</span>
<span>{data.condition}</span>
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm text-blue-600">
<div className="flex items-center gap-2">
<span>💧 Humidity:</span>
<span className="font-medium">{data.humidity}%</span>
</div>
<div className="flex items-center gap-2">
<span>🌬 Wind:</span>
<span className="font-medium">{data.windSpeed} km/h</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,92 @@
'use client'
import {
ChatWorkflowResume,
getAnnotationData,
useChatMessage,
} from '@llamaindex/chat-ui'
type CLIHumanInputData = {
command: string
}
type CLIHumanResponseData = {
execute: boolean
command: string
}
// qualified name of the event from workflow definition
const CLI_HUMAN_INPUT_EVENT_TYPE = 'cli_workflow.CLIHumanInputEvent'
const CLI_HUMAN_RESPONSE_EVENT_TYPE = 'cli_workflow.CLIHumanResponseEvent'
export function CLIHumanInput({
resumeWorkflow,
}: {
resumeWorkflow: ChatWorkflowResume
}) {
const { message } = useChatMessage()
const humanInputData = getAnnotationData<CLIHumanInputData>(
message,
CLI_HUMAN_INPUT_EVENT_TYPE
)
if (humanInputData.length === 0) return null
return (
<CLIHumanInputCard
data={humanInputData[0]}
resumeWorkflow={resumeWorkflow}
/>
)
}
function CLIHumanInputCard({
data,
resumeWorkflow,
}: {
data: CLIHumanInputData
resumeWorkflow: ChatWorkflowResume
}) {
if (!data.command) return null
const handleConfirm = async () => {
const responseData: CLIHumanResponseData = {
execute: true,
command: data.command,
}
await resumeWorkflow(CLI_HUMAN_RESPONSE_EVENT_TYPE, responseData)
}
const handleReject = async () => {
const responseData: CLIHumanResponseData = {
execute: false,
command: data.command,
}
await resumeWorkflow(CLI_HUMAN_RESPONSE_EVENT_TYPE, responseData)
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<p className="mb-4 text-base font-medium text-gray-700">
Do you want to execute the following command?
</p>
<code className="mb-6 block rounded-md border border-gray-100 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-800">
{data.command}
</code>
<div className="flex gap-3">
<button
type="button"
onClick={handleConfirm}
className="rounded-md bg-gray-900 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Execute
</button>
<button
type="button"
onClick={handleReject}
className="rounded-md border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Cancel
</button>
</div>
</div>
)
}
@@ -0,0 +1,185 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "llama-deploy-chat-ui-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@llamaindex/chat-ui": "latest",
"@radix-ui/react-select": "^2.1.1",
"ai": "^4.3.16",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
"next": "^15.3.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.2.3",
"@tailwindcss/postcss": "^4.0.7",
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@vercel/style-guide": "^5.2.0",
"eslint-config-turbo": "^2.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^4.0.7",
"tw-animate-css": "^1.3.4",
"typescript": "^5.3.3"
}
}
@@ -0,0 +1 @@
3.10
+51
View File
@@ -0,0 +1,51 @@
# LlamaDeploy Workflow Example
This example demonstrates how to use the **chat-ui** library to create a custom interface for workflows deployed with [LlamaDeploy](https://github.com/run-llama/llama_deploy).
LlamaDeploy is a system for deploying and managing LlamaIndex workflows. This example shows how you can build a React-based interface that connects to and interacts with your deployed workflows using the `useWorkflow` hook.
## Key Features
- Start new workflows
- Send events to running workflows
- Stream real-time events from workflows
## Installation
Both the SDK and the CLI are part of the LlamaDeploy Python package. To install, just run:
```bash
uv sync
```
## Running the Deployment
At this point we have all we need to run this deployment. Ideally, we would have the API server already running
somewhere in the cloud, but to get started let's start an instance locally. Run the following python script
from a shell:
```
$ uv run -m llama_deploy.apiserver
INFO: Started server process [10842]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:4501 (Press CTRL+C to quit)
```
From another shell, use the CLI, `llamactl`, to create the deployment:
```
$ uv run llamactl deploy llama_deploy.yml
Deployment successful: QuickStart
```
### UI Interface
LlamaDeploy will serve the UI through via its apiserver.
Point the browser to [http://localhost:4501/deployments/QuickStart/ui](http://localhost:4501/deployments/QuickStart/ui) to interact with your workflow through a user-friendly interface.
## Learn More
- [useWorkflow Hook](../../docs/chat-ui/hooks.mdx#useworkflow)
- [LlamaDeploy GitHub Repository](https://github.com/run-llama/llama_deploy)
- [Chat-UI Documentation](../../docs/chat-ui/)
@@ -0,0 +1,12 @@
[project]
name = "llama-deploy-workflows"
version = "0.1.0"
description = "Using LlamaDeploy with Workflow"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"llama-deploy",
]
[tool.uv.sources]
llama-deploy = { git = "https://github.com/run-llama/llama_deploy" }
@@ -19,8 +19,8 @@ class EchoWorkflow(Workflow):
async def run_step(self, ctx: Context, ev: StartEvent) -> StopEvent:
message = str(ev.get("message", ""))
# Loop, increase the counter to 10 and show it in UI
for i in range(10):
# Loop, increase the counter to 5 and show it in UI
for i in range(5):
ctx.write_event_to_stream(UIEvent(data={"counter": i}))
await ctx.set("counter", i + 1)
await asyncio.sleep(1)
@@ -0,0 +1,60 @@
const { resolve } = require('node:path')
const project = resolve(process.cwd(), 'tsconfig.json')
module.exports = {
extends: [
...[
'@vercel/style-guide/eslint/node',
'@vercel/style-guide/eslint/typescript',
'@vercel/style-guide/eslint/browser',
'@vercel/style-guide/eslint/react',
'@vercel/style-guide/eslint/next',
].map(require.resolve),
],
parserOptions: {
project,
},
globals: {
React: true,
JSX: true,
},
settings: {
'import/resolver': {
typescript: {
project,
},
node: {
extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx'],
},
},
},
ignorePatterns: ['node_modules/', 'dist/'],
// add rules configurations here
rules: {
'import/no-default-export': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'react/function-component-definition': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'no-console': 'off',
'import/order': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-alert': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'react/no-array-index-key': 'off',
'@next/next/no-img-element': 'off',
'react/jsx-sort-props': 'off',
'no-template-curly-in-string': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-await-in-loop': 'off',
'no-promise-executor-return': 'off',
'@typescript-eslint/no-loop-func': 'off',
'turbo/no-undeclared-env-vars': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'no-nested-ternary': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
},
}
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
pnpm-lock.yaml
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -8,8 +8,8 @@ import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'LlamaDeploy with Chat UI',
description: 'Chat UI Example for LlamaDeploy',
title: 'LlamaDeploy with useWorkflow',
description: 'useWorkflow Example for LlamaDeploy',
}
export default function RootLayout({
@@ -12,7 +12,7 @@ export default function Home() {
const { runId, start, stop, sendEvent, events, status } = useWorkflow({
deployment: DEPLOYMENT_NAME,
workflow: DEFAULT_WORKFLOW,
workflow,
onStopEvent: event => {
console.log('Stop event:', event)
},
@@ -42,7 +42,7 @@ export default function Home() {
return (
<div className="mx-auto h-screen w-full max-w-4xl px-4 py-4">
<h1 className="mb-6 text-2xl font-bold">Llama Deploy with Chat UI</h1>
<h1 className="mb-6 text-2xl font-bold">Llama-Deploy with useWorkflow</h1>
{/* Workflow Switcher */}
<div className="mb-4 flex items-center gap-4">
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// The base path to create proxy to nextjs frontend in LlamaDeploy
basePath: '/deployments/QuickStart/ui',
}
export default nextConfig
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -39,7 +39,7 @@ export function getInlineAnnotations(message: Message): unknown[] {
// convert annotation to inline markdown
export function toInlineAnnotation(annotation: MessageAnnotation) {
return `\`\`\`${INLINE_ANNOTATION_KEY}\n${JSON.stringify(annotation)}\n\`\`\``
return `\n\`\`\`${INLINE_ANNOTATION_KEY}\n${JSON.stringify(annotation)}\n\`\`\`\n`
}
/**
+2 -2
View File
@@ -111,7 +111,7 @@ export function ChatCanvasProvider({ children }: { children: ReactNode }) {
{
id: `restore-success-${Date.now()}`,
role: 'assistant',
content: `Successfully restored to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}\n${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
content: `Successfully restored to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
},
] as (Message & { id: string })[]
@@ -160,7 +160,7 @@ export function ChatCanvasProvider({ children }: { children: ReactNode }) {
},
{
role: 'assistant',
content: `Updated content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}\n${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
content: `Updated content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
},
] as (Message & { id: string })[]
+1
View File
@@ -199,6 +199,7 @@ function ChatInputSubmit(props: ChatInputSubmitProps) {
if (stop && isLoading) {
return (
<Button
type="button"
size="icon"
onClick={stop}
className="absolute bottom-2 right-2 rounded-full"
@@ -0,0 +1,107 @@
import {
MessageAnnotation,
MessageAnnotationType,
toInlineAnnotation,
} from '../../chat/annotations'
import { JSONValue } from '../../chat/chat.interface'
import { WorkflowEvent, WorkflowEventType } from '../use-workflow'
import { AgentStreamEvent, SourceNodesEvent, UIEvent } from './types'
import { SourceNode } from '../../widgets'
/**
* Transform a workflow event to message parts
* - if event is an agent stream event, return the delta
* - if event is an inline event, convert the data to inline annotation and return the delta
* - otherwise, return input event as Vercel annotation
* @param event - The event to transform
* @returns The message parts (delta and annotations)
*/
export function transformEventToMessageParts(event: WorkflowEvent): {
delta: string
annotations: MessageAnnotation<JSONValue>[]
} {
if (isAgentStreamEvent(event)) {
return { delta: event.data.delta, annotations: [] }
}
if (isInlineEvent(event)) {
return {
delta: toInlineAnnotation(event.data as MessageAnnotation),
annotations: [],
}
}
const annotations = toVercelAnnotations(event)
return { delta: '', annotations }
}
function isAgentStreamEvent(event: WorkflowEvent): event is AgentStreamEvent {
const hasDelta =
typeof event.data === 'object' &&
event.data !== null &&
'delta' in event.data
return event.type === WorkflowEventType.AgentStream.toString() && hasDelta
}
function isInlineEvent(event: WorkflowEvent) {
const inlineEventTypes = [WorkflowEventType.ArtifactEvent.toString()]
const hasInlineData = typeof event.data === 'object' && event.data !== null
return inlineEventTypes.includes(event.type) && hasInlineData
}
function toVercelAnnotations(event: WorkflowEvent) {
switch (event.type) {
// convert source nodes event to source nodes annotation
case WorkflowEventType.SourceNodesEvent.toString(): {
const nodes = (event as SourceNodesEvent).data?.nodes || []
if (nodes.length === 0) {
console.warn(
`No nodes found in source nodes event. Event type: ${event.type}. Data: ${JSON.stringify(event.data)}`
)
return []
}
const sources = nodes.map(({ node, score }) => ({
id: node.id_,
metadata: node.metadata,
score,
text: node.text,
url: (node.metadata?.URL as string) || '',
})) satisfies SourceNode[]
return [
{
type: MessageAnnotationType.SOURCES,
data: { nodes: sources },
},
]
}
// convert ui events to annotations
case WorkflowEventType.UIEvent.toString(): {
const uiEvent = event as UIEvent
return [
{
type: uiEvent.data.type,
data: uiEvent.data.data,
},
]
}
// for other events which are not defined, convert them to vercel annotations with type is the qualified name of the event
// eg: {"__is_pydantic": true, "value": {"item": "sample"}, "qualified_name": "myworkflow.MyEvent"}
// will be converted to this annotation: {"type": "myworkflow.MyEvent", "data": {"item": "sample"}}
// this is useful for customizing UI for events that are not defined in the chat-ui
default: {
return [
{
type: event.type,
data: event.data as JSONValue,
},
]
}
}
}
@@ -0,0 +1,117 @@
'use client'
import { useState } from 'react'
import { JSONValue, Message } from '../../chat/chat.interface'
import { useWorkflow } from '../use-workflow'
import { transformEventToMessageParts } from './helper'
import {
ChatEvent,
ChatWorkflowHookHandler,
ChatWorkflowHookParams,
} from './types'
export function useChatWorkflow({
deployment,
workflow,
baseUrl,
onError,
}: ChatWorkflowHookParams): ChatWorkflowHookHandler {
const [input, setInput] = useState<string>('')
const [messages, setMessages] = useState<Message[]>([])
const updateLastMessage = ({
delta = '',
annotations = [],
}: {
delta?: string // render events inline in markdown
annotations?: JSONValue[] // render events in annotations components
}) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1]
// if last message is assistant message, update its content
if (lastMessage?.role === 'assistant') {
return [
...prev.slice(0, -1),
{
...lastMessage,
content: (lastMessage.content || '') + delta,
annotations: [...(lastMessage.annotations || []), ...annotations],
},
]
}
// if last message is user message, add a new assistant message
return [...prev, { content: delta, role: 'assistant', annotations }]
})
}
const { start, stop, status, sendEvent } = useWorkflow<ChatEvent>({
deployment,
workflow,
baseUrl,
onData: event => {
const { delta, annotations } = transformEventToMessageParts(event)
updateLastMessage({ delta, annotations })
},
})
const append = async (newMessage: Message) => {
setMessages(prev => [...prev, newMessage])
try {
await start({ user_msg: newMessage.content, chat_history: messages })
} catch (error) {
onError?.(error)
}
return newMessage.content
}
const handleStop = async () => {
await stop()
}
const handleReload = async () => {
const lastUserMessage = [...messages]
.reverse()
.find(message => message.role === 'user')
if (!lastUserMessage) return
const chatHistory = messages.slice(0, -2)
setMessages([...chatHistory, lastUserMessage])
try {
await start({
user_msg: lastUserMessage.content,
chat_history: chatHistory,
})
} catch (error) {
onError?.(error)
}
}
// resume is used to send events to the current workflow run without creating a new task
const handleResume = async (eventType: string, eventData: any) => {
try {
await sendEvent({ type: eventType as ChatEvent['type'], data: eventData })
} catch (error) {
onError?.(error)
}
}
const isLoading = status === 'running'
return {
input,
setInput,
isLoading,
append,
messages,
setMessages,
stop: handleStop,
reload: handleReload,
resume: handleResume,
}
}
@@ -0,0 +1,3 @@
export * from './hook'
export * from './helper'
export * from './types'
@@ -0,0 +1,57 @@
import { ChatHandler, JSONValue, Message } from '../../chat/chat.interface'
import {
WorkflowEvent,
WorkflowEventType,
WorkflowHookParams,
} from '../use-workflow'
export interface ChatEvent extends WorkflowEvent {
type: WorkflowEventType.StartEvent
data: {
user_msg: string
chat_history: Omit<Message, 'annotations'>[]
}
}
export interface AgentStreamEvent extends WorkflowEvent {
type: WorkflowEventType.AgentStream
data: {
delta: string
}
}
export interface SourceNodesEvent extends WorkflowEvent {
type: WorkflowEventType.SourceNodesEvent
data: {
nodes: {
node: {
id_: string
metadata: Record<string, JSONValue>
text: string
}
score: number
}[]
}
}
export interface UIEvent extends WorkflowEvent {
type: WorkflowEventType.UIEvent
data: {
type: string
data: JSONValue
}
}
export type ChatWorkflowHookParams = Pick<
WorkflowHookParams,
'deployment' | 'workflow' | 'baseUrl' | 'onError'
>
export type ChatWorkflowHookHandler = ChatHandler & {
resume: ChatWorkflowResume
}
export type ChatWorkflowResume = (
eventType: string,
eventData: JSONValue
) => Promise<void>
@@ -23,6 +23,7 @@ export function useWorkflow<E extends WorkflowEvent = WorkflowEvent>(
deployment: deploymentName,
workflow,
runId: initialTaskId,
onData,
onStopEvent,
onError,
} = params
@@ -46,6 +47,7 @@ export function useWorkflow<E extends WorkflowEvent = WorkflowEvent>(
},
onData: event => {
setEvents(prev => [...prev, event])
onData?.(event)
},
onError: (error: Error) => {
setStatus('error')
@@ -60,7 +62,7 @@ export function useWorkflow<E extends WorkflowEvent = WorkflowEvent>(
}
)
},
[client, deploymentName, onError, onStopEvent]
[client, deploymentName, onData, onError, onStopEvent]
)
// if task id is provided, get existing task and restore its events
@@ -13,6 +13,7 @@ export interface WorkflowHookParams<E extends WorkflowEvent = WorkflowEvent> {
deployment: string // Name of the registered deployment
runId?: string // Optional task ID for resuming a workflow task
workflow?: string // Set the default service to run
onData?: (event: WorkflowEvent) => void
onStopEvent?: (event: E) => void
onError?: (error: any) => void
}
@@ -37,6 +38,10 @@ export type WorkflowTask = TaskDefinition & {
export enum WorkflowEventType {
StartEvent = 'llama_index.core.workflow.events.StartEvent',
StopEvent = 'llama_index.core.workflow.events.StopEvent',
AgentStream = 'llama_index.core.agent.workflow.workflow_events.AgentStream',
SourceNodesEvent = 'llama_index.server.models.source_nodes.SourceNodesEvent',
ArtifactEvent = 'llama_index.server.models.artifacts.ArtifactEvent',
UIEvent = 'llama_index.server.models.ui.UIEvent',
}
export interface StreamingEventCallback<
+1
View File
@@ -21,3 +21,4 @@ export { useChatMessages } from './chat/chat-messages'
// Custom Hooks
export { useFile } from './hook/use-file'
export * from './hook/use-workflow'
export * from './hook/use-chat-workflow'
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { transformEventToMessageParts } from '../../hook/use-chat-workflow/helper'
import { WorkflowEventType, WorkflowEvent } from '../../hook/use-workflow/types'
import { MessageAnnotationType } from '../../chat/annotations/types'
describe('useChatWorkflow - transformEventToMessageParts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('AgentStreamEvent handling', () => {
it('should return delta for agent stream events', () => {
const event: WorkflowEvent = {
type: WorkflowEventType.AgentStream.toString(),
data: {
delta: 'Hello, world!',
},
}
const result = transformEventToMessageParts(event)
expect(result).toEqual({
delta: 'Hello, world!',
annotations: [],
})
})
it('should handle empty delta in agent stream events', () => {
const event: WorkflowEvent = {
type: WorkflowEventType.AgentStream.toString(),
data: {
delta: '',
},
}
const result = transformEventToMessageParts(event)
expect(result).toEqual({
delta: '',
annotations: [],
})
})
})
describe('ArtifactEvent handling (inline events)', () => {
it('should convert artifact events to inline annotations', () => {
const artifactData = {
type: MessageAnnotationType.ARTIFACT,
data: {
title: 'Test Artifact',
content: 'Some artifact content',
},
}
const event: WorkflowEvent = {
type: WorkflowEventType.ArtifactEvent.toString(),
data: artifactData,
}
const result = transformEventToMessageParts(event)
expect(result.delta).toContain('```annotation')
expect(result.delta).toContain(JSON.stringify(artifactData))
expect(result.annotations).toEqual([])
})
it('should handle artifact events with complex data', () => {
const complexArtifactData = {
type: MessageAnnotationType.ARTIFACT,
data: {
title: 'Complex Artifact',
content: {
code: 'console.log("hello")',
language: 'javascript',
metadata: {
author: 'test',
created: new Date().toISOString(),
},
},
},
}
const event: WorkflowEvent = {
type: WorkflowEventType.ArtifactEvent.toString(),
data: complexArtifactData,
}
const result = transformEventToMessageParts(event)
expect(result.delta).toContain('```annotation')
expect(result.delta).toContain(JSON.stringify(complexArtifactData))
expect(result.annotations).toEqual([])
})
})
describe('SourceNodesEvent handling', () => {
it('should convert source nodes events to vercel annotations', () => {
const event: WorkflowEvent = {
type: WorkflowEventType.SourceNodesEvent.toString(),
data: {
nodes: [
{
node: {
id_: 'node-1',
metadata: {
title: 'Test Document',
URL: 'https://example.com/doc1',
},
text: 'This is the content of node 1',
},
score: 0.95,
},
{
node: {
id_: 'node-2',
metadata: {
title: 'Another Document',
URL: 'https://example.com/doc2',
},
text: 'This is the content of node 2',
},
score: 0.87,
},
],
},
}
const result = transformEventToMessageParts(event)
expect(result.delta).toBe('')
expect(result.annotations).toHaveLength(1)
expect(result.annotations[0]).toEqual({
type: MessageAnnotationType.SOURCES,
data: {
nodes: [
{
id: 'node-1',
metadata: {
title: 'Test Document',
URL: 'https://example.com/doc1',
},
score: 0.95,
text: 'This is the content of node 1',
url: 'https://example.com/doc1',
},
{
id: 'node-2',
metadata: {
title: 'Another Document',
URL: 'https://example.com/doc2',
},
score: 0.87,
text: 'This is the content of node 2',
url: 'https://example.com/doc2',
},
],
},
})
})
it('should handle source nodes events with empty nodes array', () => {
const consoleSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
const event: WorkflowEvent = {
type: WorkflowEventType.SourceNodesEvent.toString(),
data: {
nodes: [],
},
}
const result = transformEventToMessageParts(event)
expect(result.delta).toBe('')
expect(result.annotations).toEqual([])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('No nodes found in source nodes event')
)
consoleSpy.mockRestore()
})
it('should handle source nodes without URL metadata', () => {
const event: WorkflowEvent = {
type: WorkflowEventType.SourceNodesEvent.toString(),
data: {
nodes: [
{
node: {
id_: 'node-without-url',
metadata: {
title: 'Document without URL',
},
text: 'Content without URL',
},
score: 0.8,
},
],
},
}
const result = transformEventToMessageParts(event)
expect(result.annotations[0].data).toHaveProperty('nodes')
expect((result.annotations[0].data as any).nodes[0]).toEqual({
id: 'node-without-url',
metadata: {
title: 'Document without URL',
},
score: 0.8,
text: 'Content without URL',
url: '',
})
})
})
describe('UIEvent handling', () => {
it('should convert UI events to vercel annotations', () => {
const event: WorkflowEvent = {
type: WorkflowEventType.UIEvent.toString(),
data: {
type: 'weather',
data: {
location: 'San Francisco',
temperature: 22,
condition: 'sunny',
humidity: 50,
windSpeed: 10,
},
},
}
const result = transformEventToMessageParts(event)
expect(result.delta).toBe('')
expect(result.annotations).toHaveLength(1)
expect(result.annotations[0]).toEqual({
type: 'weather',
data: {
location: 'San Francisco',
temperature: 22,
condition: 'sunny',
humidity: 50,
windSpeed: 10,
},
})
})
})
})
@@ -14,6 +14,7 @@ import { DocxIcon } from '../ui/icons/docx'
import { PDFIcon } from '../ui/icons/pdf'
import { SheetIcon } from '../ui/icons/sheet'
import { TxtIcon } from '../ui/icons/txt'
import { JSONValue } from '../chat/chat.interface'
export type DocumentFile = {
id: string
@@ -26,7 +27,7 @@ export type DocumentFile = {
export type SourceNode = {
id: string
metadata: Record<string, unknown>
metadata: Record<string, JSONValue>
score?: number
text: string
url?: string
+4 -1
View File
@@ -2,5 +2,8 @@ packages:
- "apps/*"
- "packages/*"
- "examples/**"
- '!examples/llama-deploy/ui' # UI will be built separately by llama-deploy server
- "docs"
# UI will be built separately by llama-deploy server
- '!examples/llamadeploy-workflow/ui'
- '!examples/llamadeploy-chat/ui'