feat: add llamadeploy + llamaindexserver example (#150)

This commit is contained in:
Thuc Pham
2025-07-01 13:45:42 +07:00
committed by GitHub
parent 94bb08c2ef
commit f28cb7e791
17 changed files with 810 additions and 8 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@llamaindex/chat-ui': patch
'@llamaindex/chat-ui-docs': patch
---
feat: add llamadexploy + ts-server example
+9 -3
View File
@@ -672,7 +672,9 @@ Your LlamaDeploy workflows can send three main types of events to enhance the ch
Send source nodes to display citations and references for generated content:
```python
from llama_index.server.models import SourceNodesEvent
from llama_index.core.chat_ui.events import (
SourceNodesEvent,
)
from llama_index.core.schema import NodeWithScore
from llama_index.core.data_structs import Node
@@ -698,7 +700,9 @@ ctx.write_event_to_stream(
Send code snippets, documents, or other artifacts that can be displayed in a dedicated canvas:
```python
from llama_index.server.models import ArtifactEvent
from llama_index.core.chat_ui.events import (
ArtifactEvent,
)
import time
ctx.write_event_to_stream(
@@ -723,7 +727,9 @@ ctx.write_event_to_stream(
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 llama_index.core.chat_ui.events import (
UIEvent,
)
from pydantic import BaseModel
class WeatherData(BaseModel):
+1 -1
View File
@@ -14,7 +14,7 @@ services:
path: src/chat_workflow:workflow
python-dependencies:
- llama-index-llms-openai>=0.4.5
- llama-index-server>=0.1.22
- llama-index-core>=0.12.45
agent_workflow:
name: Agent Workflow
source:
@@ -12,7 +12,7 @@ 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 (
from llama_index.core.chat_ui.events import (
SourceNodesEvent,
ArtifactEvent,
UIEvent,
@@ -0,0 +1,2 @@
.env
pnpm-lock.yaml
@@ -0,0 +1 @@
3.10
@@ -0,0 +1,55 @@
# LlamaDeploy + LlamaIndexServer Example
This example demonstrates how to use **LlamaIndexServer** as a frontend chat interface for workflows deployed with [LlamaDeploy](https://github.com/run-llama/llama_deploy).
LlamaDeploy is a system for deploying and managing LlamaIndex workflows, while LlamaIndexServer provides a pre-built TypeScript server with an integrated chat UI that can connect directly to LlamaDeploy deployments. This example shows how you can quickly set up a complete chat application by combining these two technologies without needing to build custom UI components.
## 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: chat
```
### UI Interface
LlamaDeploy will serve the UI through the apiserver. Point the browser to [http://localhost:4501/deployments/chat/ui](http://localhost:4501/deployments/chat/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,24 @@
name: chat
control-plane:
port: 8000
default-service: workflow
services:
workflow:
name: Workflow
source:
type: local
name: src
path: src/workflow:workflow
python-dependencies:
- llama-index-llms-openai>=0.4.5
- llama-index-core>=0.12.45
ui:
name: My Nextjs App
port: 3000
source:
type: local
name: ui
@@ -0,0 +1,12 @@
[project]
name = "llama-deploy-chat-ui"
version = "0.1.0"
description = "Using LlamaDeploy with Chat UI"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"llama-deploy",
]
[tool.uv.sources]
llama-deploy = { git = "https://github.com/run-llama/llama_deploy" }
@@ -0,0 +1,131 @@
import json
import re
from typing import List, Optional, Any
from pydantic import ValidationError
from llama_index.core.chat_ui.models.artifact import (
Artifact,
ArtifactType,
CodeArtifactData,
DocumentArtifactData,
)
from llama_index.core.llms import ChatMessage
INLINE_ANNOTATION_KEY = "annotation"
def get_inline_annotations(message: ChatMessage) -> List[Any]:
"""Extract inline annotations from a chat message."""
markdown_content = message.content
inline_annotations: List[Any] = []
# Regex to match annotation code blocks
# Matches ```annotation followed by content until closing ```
annotation_regex = re.compile(
rf"```{re.escape(INLINE_ANNOTATION_KEY)}\s*\n([\s\S]*?)\n```", re.MULTILINE
)
for match in annotation_regex.finditer(markdown_content):
json_content = match.group(1).strip() if match.group(1) else None
if not json_content:
continue
try:
# Parse the JSON content
parsed = json.loads(json_content)
# Check for required fields in the parsed annotation
if (
not isinstance(parsed, dict)
or "type" not in parsed
or "data" not in parsed
):
continue
# Extract the annotation data
inline_annotations.append(parsed)
except (json.JSONDecodeError, ValidationError) as error:
# Skip invalid annotations - they might be malformed JSON or invalid schema
print(f"Failed to parse annotation: {error}")
return inline_annotations
def artifact_from_message(message: ChatMessage) -> Optional[Artifact]:
"""Create an artifact from a chat message if it contains artifact annotations."""
inline_annotations = get_inline_annotations(message)
for annotation in inline_annotations:
if isinstance(annotation, dict) and annotation.get("type") == "artifact":
try:
# Create artifact data based on type
artifact_data = annotation.get("data")
if not artifact_data:
continue
artifact_type = artifact_data.get("type")
if artifact_type == "code":
# Get the nested data object that contains the actual code information
code_info = artifact_data.get("data", {})
code_data = CodeArtifactData(
file_name=code_info.get("file_name", ""),
code=code_info.get("code", ""),
language=code_info.get("language", ""),
)
artifact = Artifact(
created_at=artifact_data.get("created_at"),
type=ArtifactType.CODE,
data=code_data,
)
elif artifact_type == "document":
# Get the nested data object that contains the actual document information
doc_info = artifact_data.get("data", {})
doc_data = DocumentArtifactData(
title=doc_info.get("title", ""),
content=doc_info.get("content", ""),
type=doc_info.get("type", "markdown"),
sources=doc_info.get("sources"),
)
artifact = Artifact(
created_at=artifact_data.get("created_at"),
type=ArtifactType.DOCUMENT,
data=doc_data,
)
else:
continue
return artifact
except Exception as e:
print(
f"Failed to parse artifact from annotation: {annotation}. Error: {e}"
)
return None
def get_artifacts(chat_history: List[ChatMessage]) -> List[Artifact]:
"""
Return a list of artifacts sorted by their creation time.
Artifacts without a creation time are placed at the end.
"""
artifacts = []
for message in chat_history:
artifact = artifact_from_message(message)
if artifact is not None:
artifacts.append(artifact)
# Sort by creation time, with None values at the end
return sorted(
artifacts,
key=lambda a: (a.created_at is None, a.created_at),
)
def get_last_artifact(chat_history: List[ChatMessage]) -> Optional[Artifact]:
"""Get the last artifact from chat history."""
artifacts = get_artifacts(chat_history)
return artifacts[-1] if len(artifacts) > 0 else None
@@ -0,0 +1,365 @@
import re
import time
from typing import Any, Literal, Optional, Union, List
from pydantic import BaseModel
from llama_index.core.llms import LLM, ChatMessage
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.prompts import PromptTemplate
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
from llama_index.core.chat_ui.events import (
UIEvent,
ArtifactEvent,
)
from llama_index.core.chat_ui.models.artifact import (
Artifact,
ArtifactType,
CodeArtifactData,
)
from llama_index.llms.openai import OpenAI
from src.utils import get_last_artifact
class Requirement(BaseModel):
next_step: Literal["answering", "coding"]
language: Optional[str] = None
file_name: Optional[str] = None
requirement: str
class PlanEvent(Event):
user_msg: str
context: Optional[str] = None
class GenerateArtifactEvent(Event):
requirement: Requirement
class SynthesizeAnswerEvent(Event):
pass
class UIEventData(BaseModel):
state: Literal["plan", "generate", "completed"]
requirement: Optional[str] = None
class ArtifactWorkflow(Workflow):
"""
A simple workflow that help generate/update the chat artifact (code, document)
e.g: Help create a NextJS app.
Update the generated code with the user's feedback.
Generate a guideline for the app,...
"""
def __init__(
self,
llm: LLM,
**kwargs: Any,
):
"""
Args:
llm: The LLM to use.
chat_request: The chat request from the chat app to use.
"""
super().__init__(**kwargs)
self.llm = llm
self.last_artifact: Optional[Artifact] = None
@step
async def prepare_chat_history(self, ctx: Context, ev: StartEvent) -> PlanEvent:
user_msg: str = ev.get("user_msg")
chat_history = [
ChatMessage(role=msg.get("role", "user"), content=msg.get("content", ""))
for msg in ev.get("chat_history", [])
]
messages = [*chat_history, ChatMessage(role="user", content=user_msg)]
# extract inline artifact from chat history
last_artifact = get_last_artifact(messages)
self.last_artifact = last_artifact
memory = ChatMemoryBuffer.from_defaults(
chat_history=messages,
llm=self.llm,
)
await ctx.set("memory", memory)
return PlanEvent(
user_msg=user_msg,
context=(str(last_artifact.model_dump_json()) if last_artifact else ""),
)
@step
async def planning(
self, ctx: Context, event: PlanEvent
) -> Union[GenerateArtifactEvent, SynthesizeAnswerEvent]:
"""
Based on the conversation history and the user's request
this step will help to provide a good next step for the code or document generation.
"""
ctx.write_event_to_stream(
UIEvent(
type="ui_event",
data=UIEventData(
state="plan",
requirement=None,
),
)
)
prompt = PromptTemplate(
"""
You are a product analyst responsible for analyzing the user's request and providing the next step for code or document generation.
You are helping user with their code artifact. To update the code, you need to plan a coding step.
Follow these instructions:
1. Carefully analyze the conversation history and the user's request to determine what has been done and what the next step should be.
2. The next step must be one of the following two options:
- "coding": To make the changes to the current code.
- "answering": If you don't need to update the current code or need clarification from the user.
Important: Avoid telling the user to update the code themselves, you are the one who will update the code (by planning a coding step).
3. If the next step is "coding", you may specify the language ("typescript" or "python") and file_name if known, otherwise set them to null.
4. The requirement must be provided clearly what is the user request and what need to be done for the next step in details
as precise and specific as possible, don't be stingy with in the requirement.
5. If the next step is "answering", set language and file_name to null, and the requirement should describe what to answer or explain to the user.
6. Be concise; only return the requirements for the next step.
7. The requirements must be in the following format:
```json
{
"next_step": "answering" | "coding",
"language": "typescript" | "python" | null,
"file_name": string | null,
"requirement": string
}
```
## Example 1:
User request: Create a calculator app.
You should return:
```json
{
"next_step": "coding",
"language": "typescript",
"file_name": "calculator.tsx",
"requirement": "Generate code for a calculator app that has a simple UI with a display and button layout. The display should show the current input and the result. The buttons should include basic operators, numbers, clear, and equals. The calculation should work correctly."
}
```
## Example 2:
User request: Explain how the game loop works.
Context: You have already generated the code for a snake game.
You should return:
```json
{
"next_step": "answering",
"language": null,
"file_name": null,
"requirement": "The user is asking about the game loop. Explain how the game loop works."
}
```
{context}
Now, plan the user's next step for this request:
{user_msg}
"""
).format(
context=(
""
if event.context is None
else f"## The context is: \n{event.context}\n"
),
user_msg=event.user_msg,
)
response = await self.llm.acomplete(
prompt=prompt,
formatted=True,
)
# parse the response to Requirement
# 1. use regex to find the json block
json_block = re.search(
r"```(?:json)?\s*([\s\S]*?)\s*```", response.text, re.IGNORECASE
)
if json_block is None:
raise ValueError("No JSON block found in the response.")
# 2. parse the json block to Requirement
requirement = Requirement.model_validate_json(json_block.group(1).strip())
ctx.write_event_to_stream(
UIEvent(
type="ui_event",
data=UIEventData(
state="generate",
requirement=requirement.requirement,
),
)
)
# Put the planning result to the memory
# useful for answering step
memory: ChatMemoryBuffer = await ctx.get("memory")
memory.put(
ChatMessage(
role="assistant",
content=f"The plan for next step: \n{response.text}",
)
)
await ctx.set("memory", memory)
if requirement.next_step == "coding":
return GenerateArtifactEvent(
requirement=requirement,
)
else:
return SynthesizeAnswerEvent()
@step
async def generate_artifact(
self, ctx: Context, event: GenerateArtifactEvent
) -> SynthesizeAnswerEvent:
"""
Generate the code based on the user's request.
"""
ctx.write_event_to_stream(
UIEvent(
type="ui_event",
data=UIEventData(
state="generate",
requirement=event.requirement.requirement,
),
)
)
prompt = PromptTemplate(
"""
You are a skilled developer who can help user with coding.
You are given a task to generate or update a code for a given requirement.
## Follow these instructions:
**1. Carefully read the user's requirements.**
If any details are ambiguous or missing, make reasonable assumptions and clearly reflect those in your output.
If the previous code is provided:
+ Carefully analyze the code with the request to make the right changes.
+ Avoid making a lot of changes from the previous code if the request is not to write the code from scratch again.
**2. For code requests:**
- If the user does not specify a framework or language, default to a React component using the Next.js framework.
- For Next.js, use Shadcn UI components, Typescript, @types/node, @types/react, @types/react-dom, PostCSS, and TailwindCSS.
The import pattern should be:
```
import { ComponentName } from "@/components/ui/component-name"
import { Markdown } from "@llamaindex/chat-ui"
import { cn } from "@/lib/utils"
```
- Ensure the code is idiomatic, production-ready, and includes necessary imports.
- Only generate code relevant to the user's request—do not add extra boilerplate.
**3. Don't be verbose on response**
- No other text or comments only return the code which wrapped by ```language``` block.
- If the user's request is to update the code, only return the updated code.
**4. Only the following languages are allowed: "typescript", "python".**
**5. If there is no code to update, return the reason without any code block.**
## Example:
```typescript
import React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export default function MyComponent() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<Button>Click me</Button>
</div>
);
}
The previous code is:
{previous_artifact}
Now, i have to generate the code for the following requirement:
{requirement}
```
"""
).format(
previous_artifact=(
self.last_artifact.model_dump_json() if self.last_artifact else ""
),
requirement=event.requirement,
)
response = await self.llm.acomplete(
prompt=prompt,
formatted=True,
)
# Extract the code from the response
language_pattern = r"```(\w+)([\s\S]*)```"
code_match = re.search(language_pattern, response.text)
if code_match is None:
return SynthesizeAnswerEvent()
else:
code = code_match.group(2).strip()
# Put the generated code to the memory
memory: ChatMemoryBuffer = await ctx.get("memory")
memory.put(
ChatMessage(
role="assistant",
content=f"Updated the code: \n{response.text}",
)
)
# To show the Canvas panel for the artifact
ctx.write_event_to_stream(
ArtifactEvent(
data=Artifact(
type=ArtifactType.CODE,
created_at=int(time.time()),
data=CodeArtifactData(
language=event.requirement.language or "",
file_name=event.requirement.file_name or "",
code=code,
),
),
)
)
return SynthesizeAnswerEvent()
@step
async def synthesize_answer(
self, ctx: Context, event: SynthesizeAnswerEvent
) -> StopEvent:
"""
Synthesize the answer.
"""
memory: ChatMemoryBuffer = await ctx.get("memory")
chat_history = memory.get()
chat_history.append(
ChatMessage(
role="system",
content="""
You are a helpful assistant who is responsible for explaining the work to the user.
Based on the conversation history, provide an answer to the user's question.
The user has access to the code so avoid mentioning the whole code again in your response.
""",
)
)
response_stream = await self.llm.astream_chat(
messages=chat_history,
)
ctx.write_event_to_stream(
UIEvent(
type="ui_event",
data=UIEventData(
state="completed",
),
)
)
return StopEvent(result=response_stream)
workflow = ArtifactWorkflow(
# TODO: how can we pass the data from the chat request to the workflow?
# evaluate option to send the chat_request with the start event (instead of the chat_history) - this depends on the llama_deploy implementation
llm=OpenAI(model="gpt-4o-mini"),
)
@@ -0,0 +1,132 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Markdown } from "@llamaindex/chat-ui/widgets";
import { ListChecks, Loader2, Wand2 } from "lucide-react";
import { useEffect, useState } from "react";
const STAGE_META = {
plan: {
icon: ListChecks,
badgeText: "Step 1/2: Planning",
gradient: "from-blue-100 via-blue-50 to-white",
progress: 33,
iconBg: "bg-blue-100 text-blue-600",
badge: "bg-blue-100 text-blue-700",
},
generate: {
icon: Wand2,
badgeText: "Step 2/2: Generating",
gradient: "from-violet-100 via-violet-50 to-white",
progress: 66,
iconBg: "bg-violet-100 text-violet-600",
badge: "bg-violet-100 text-violet-700",
},
};
function ArtifactWorkflowCard({ event }) {
const [ visible, setVisible ] = useState(event?.state !== "completed");
const [ fade, setFade ] = useState(false);
useEffect(() => {
if (event?.state === "completed") {
setVisible(false);
} else {
setVisible(true);
setFade(false);
}
}, [ event?.state ]);
if (!event || !visible) return null;
const { state, requirement } = event;
const meta = STAGE_META[ state ];
if (!meta) return null;
return (
<div className="flex min-h-[180px] w-full items-center justify-center py-2">
<Card
className={cn(
"w-full rounded-xl shadow-md transition-all duration-500",
"border-0",
fade && "pointer-events-none opacity-0",
`bg-gradient-to-br ${meta.gradient}`,
)}
style={{
boxShadow:
"0 2px 12px 0 rgba(80, 80, 120, 0.08), 0 1px 3px 0 rgba(80, 80, 120, 0.04)",
}}
>
<CardHeader className="flex flex-row items-center gap-2 px-3 pb-1 pt-2">
<div
className={cn(
"flex items-center justify-center rounded-full p-1",
meta.iconBg,
)}
>
<meta.icon className="h-5 w-5" />
</div>
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Badge className={cn("ml-1", meta.badge, "px-2 py-0.5 text-xs")}>
{meta.badgeText}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-3 py-1">
{state === "plan" && (
<div className="flex flex-col items-center gap-2 py-2">
<Loader2 className="mb-1 h-6 w-6 animate-spin text-blue-400" />
<div className="text-center text-sm font-medium text-blue-900">
Analyzing your request...
</div>
<Skeleton className="mt-1 h-3 w-1/2 rounded-full" />
</div>
)}
{state === "generate" && (
<div className="flex flex-col gap-2 py-2">
<div className="flex items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-violet-400" />
<span className="text-sm font-medium text-violet-900">
Working on the requirement:
</span>
</div>
<div className="max-h-24 overflow-auto rounded-lg border border-violet-200 bg-violet-50 px-2 py-1 text-xs">
{requirement ? (
<Markdown content={requirement} />
) : (
<span className="italic text-violet-400">
No requirements available yet.
</span>
)}
</div>
</div>
)}
</CardContent>
<div className="px-3 pb-2 pt-1">
<Progress
value={meta.progress}
className={cn(
"h-1 rounded-full bg-gray-200",
state === "plan" && "bg-blue-200",
state === "generate" && "bg-violet-200",
)}
/>
</div>
</Card>
</div>
);
}
export default function Component({ events }) {
const aggregateEvents = () => {
if (!events || events.length === 0) return null;
return events[ events.length - 1 ];
};
const event = aggregateEvents();
return <ArtifactWorkflowCard event={event} />;
}
@@ -0,0 +1,14 @@
import { LlamaIndexServer } from '@llamaindex/server'
new LlamaIndexServer({
uiConfig: {
starterQuestions: ['Generate calculator app', 'Generate todo list app'],
componentsDir: 'components',
layoutDir: 'layout',
llamaDeploy: {
deployment: 'chat',
workflow: 'workflow',
},
},
port: 3000,
}).start()
@@ -0,0 +1,35 @@
'use client'
import { Sparkles, Star } from 'lucide-react'
export default function Header() {
return (
<div className="flex items-center justify-between p-2 px-4">
<div className="flex items-center gap-2">
<Sparkles className="size-4" />
<h1 className="font-semibold">LlamaIndex App</h1>
</div>
<div className="flex items-center justify-end gap-4">
<div className="flex items-center gap-2">
<a
href="https://www.llamaindex.ai/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Built by LlamaIndex
</a>
</div>
<a
href="https://github.com/run-llama/LlamaIndexTS"
target="_blank"
rel="noopener noreferrer"
className="hover:bg-accent flex items-center gap-2 rounded-md border border-gray-300 px-2 py-1 text-sm"
>
<Star className="size-4" />
Star on GitHub
</a>
</div>
</div>
)
}
@@ -0,0 +1,18 @@
{
"name": "llamaindex-server-ui",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "nodemon --exec tsx index.ts"
},
"dependencies": {
"@llamaindex/server": "latest",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@types/node": "^20.10.3",
"nodemon": "^3.1.10",
"tsx": "4.7.2",
"typescript": "^5.3.2"
}
}
@@ -39,9 +39,9 @@ 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',
SourceNodesEvent = 'llama_index.core.chat_ui.events.SourceNodesEvent',
ArtifactEvent = 'llama_index.core.chat_ui.events.ArtifactEvent',
UIEvent = 'llama_index.core.chat_ui.events.UIEvent',
}
export interface StreamingEventCallback<
+1
View File
@@ -7,3 +7,4 @@ packages:
# UI will be built separately by llama-deploy server
- "!examples/llamadeploy/workflow/ui"
- "!examples/llamadeploy/chat/ui"
- "!examples/llamadeploy/with-tsserver/ui"