mirror of
https://github.com/run-llama/chat-ui.git
synced 2026-07-21 03:15:21 -04:00
feat: useChatWorkflow (#140)
--------- Co-authored-by: leehuwuj <leehuwuj@gmail.com> Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@llamaindex/chat-ui': patch
|
||||
---
|
||||
|
||||
feat: useChatWorkflow
|
||||
+197
-21
@@ -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.
|
||||
|
||||
@@ -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/)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
+2
-2
@@ -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 |
+2
-2
@@ -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({
|
||||
+2
-2
@@ -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`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })[]
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user