Add workflow/chat history (#6)

This commit is contained in:
Adrian Lyjak
2025-09-23 15:02:19 -04:00
committed by GitHub
parent 947af97a56
commit 0ae0f8d12e
26 changed files with 2236 additions and 1196 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
[project]
name = "{{ project_name_snake }}"
name = "{{ project_name }}"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
@@ -7,7 +7,7 @@ authors = []
requires-python = ">=3.12"
dependencies = [
"llama-index-workflows>=2.2.0,<3.0.0",
"llama-cloud-services>=0.6.68",
"llama-cloud-services>=0.6.69",
"llama-index-core>=0.14.0",
"llama-index-llms-openai>=0.5.6",
"llama-index-embeddings-openai>=0.5.1",
+19 -5
View File
@@ -3,7 +3,8 @@ import os
import httpx
from llama_cloud.client import AsyncLlamaCloud
from llama_cloud_services import LlamaParse
from llama_cloud_services import LlamaCloudIndex, LlamaParse
from llama_cloud_services.parse import ResultType
# deployed agents may infer their name from the deployment name
# Note: Make sure that an agent deployment with this name actually exists
@@ -18,7 +19,8 @@ LLAMA_CLOUD_PROJECT_ID = os.getenv("LLAMA_DEPLOY_PROJECT_ID")
INDEX_NAME = "document_qa_index"
def get_custom_client() -> httpx.AsyncClient:
@functools.cache
def get_base_cloud_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=60,
headers={"Project-Id": LLAMA_CLOUD_PROJECT_ID}
@@ -32,7 +34,7 @@ def get_llama_cloud_client() -> AsyncLlamaCloud:
return AsyncLlamaCloud(
base_url=LLAMA_CLOUD_BASE_URL,
token=LLAMA_CLOUD_API_KEY,
httpx_client=get_custom_client(),
httpx_client=get_base_cloud_client(),
)
@@ -45,8 +47,20 @@ def get_llama_parse_client() -> LlamaParse:
adaptive_long_table=True,
outlined_table_extraction=True,
output_tables_as_HTML=True,
result_type="markdown",
result_type=ResultType.MD,
api_key=LLAMA_CLOUD_API_KEY,
project_id=LLAMA_CLOUD_PROJECT_ID,
custom_client=get_custom_client(),
custom_client=get_base_cloud_client(),
)
@functools.lru_cache(maxsize=None)
def get_index(index_name: str) -> LlamaCloudIndex:
return LlamaCloudIndex.create_index(
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
show_progress=True,
custom_client=get_base_cloud_client(),
)
+129 -132
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
from datetime import datetime
import logging
import os
import tempfile
from typing import Any, Literal
import httpx
from dotenv import load_dotenv
from llama_cloud.types import RetrievalMode
from llama_index.core import Settings
from llama_index.core.chat_engine.types import BaseChatEngine, ChatMode
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.chat_engine.types import (
BaseChatEngine,
ChatMode,
)
from llama_index.core.llms import ChatMessage
import asyncio
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_cloud_services import LlamaCloudIndex
from pydantic import BaseModel, Field
from workflows import Workflow, step, Context
from workflows.events import (
StartEvent,
@@ -22,17 +27,12 @@ from workflows.events import (
from workflows.retry_policy import ConstantDelayRetryPolicy
from .clients import (
LLAMA_CLOUD_API_KEY,
LLAMA_CLOUD_BASE_URL,
get_custom_client,
get_index,
get_llama_cloud_client,
get_llama_parse_client,
LLAMA_CLOUD_PROJECT_ID,
)
load_dotenv()
logger = logging.getLogger(__name__)
@@ -53,15 +53,13 @@ class FileDownloadedEvent(Event):
class ChatEvent(StartEvent):
index_name: str
session_id: str
conversation_history: list[ConversationMessage] = Field(default_factory=list)
# Configure LLM and embedding model
Settings.llm = OpenAI(model="gpt-4", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
custom_client = get_custom_client()
class DocumentUploadWorkflow(Workflow):
"""Workflow to upload and index documents using LlamaParse and LlamaCloud Index"""
@@ -131,15 +129,7 @@ class DocumentUploadWorkflow(Workflow):
documents = result.get_text_documents()
# Create or connect to LlamaCloud Index
index = LlamaCloudIndex.create_index(
documents=documents,
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
show_progress=True,
custom_client=custom_client,
)
index = get_index(index_name)
# Insert documents to index
logger.info(f"Inserting {len(documents)} documents to {index_name}")
@@ -158,18 +148,14 @@ class DocumentUploadWorkflow(Workflow):
)
except Exception as e:
logger.error(e.stack_trace)
return StopEvent(
result={"success": False, "error": str(e), "stack_trace": e.stack_trace}
)
logger.error(f"Error parsing document {ev.file_id}: {e}", exc_info=True)
return StopEvent(result={"success": False, "error": str(e)})
class ChatResponseEvent(Event):
"""Event emitted when chat engine generates a response"""
class AppendChatMessage(Event):
"""Event emitted when chat engine appends a message to the conversation history"""
response: str
sources: list
query: str
message: ConversationMessage
class ChatDeltaEvent(Event):
@@ -178,88 +164,119 @@ class ChatDeltaEvent(Event):
delta: str
class QueryConversationHistoryEvent(HumanResponseEvent):
"""Client can call this to trigger replaying AppendChatMessage events"""
pass
class ErrorEvent(Event):
"""Event emitted when an error occurs"""
error: str
class ChatWorkflowState(BaseModel):
index_name: str | None = None
conversation_history: list[ConversationMessage] = Field(default_factory=list)
def chat_messages(self) -> list[ChatMessage]:
return [
ChatMessage(role=message.role, content=message.text)
for message in self.conversation_history
]
class SourceMessage(BaseModel):
text: str
score: float
metadata: dict[str, Any]
class ConversationMessage(BaseModel):
"""
Mostly just a wrapper for a ChatMessage with extra context for UI. Includes a timestamp and source references.
"""
role: Literal["user", "assistant"]
text: str
sources: list[SourceMessage] = Field(default_factory=list)
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
def get_chat_engine(index_name: str) -> BaseChatEngine:
index = get_index(index_name)
return index.as_chat_engine(
chat_mode=ChatMode.CONTEXT,
llm=Settings.llm,
context_prompt=(
"You are a helpful assistant that answers questions based on the provided documents. "
"Always cite specific information from the documents when answering. "
"If you cannot find the answer in the documents, say so clearly."
),
)
class ChatWorkflow(Workflow):
"""Workflow to handle continuous chat queries against indexed documents"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.chat_engines: dict[
str, BaseChatEngine
] = {} # Cache chat engines per index
@step
async def initialize_chat(self, ev: ChatEvent, ctx: Context) -> InputRequiredEvent:
async def initialize_chat(
self, ev: ChatEvent, ctx: Context[ChatWorkflowState]
) -> InputRequiredEvent | StopEvent | None:
"""Initialize the chat session and request first input"""
try:
logger.info(f"Initializing chat {ev.index_name}")
index_name = ev.index_name
session_id = ev.session_id
initial_state = await ctx.store.get_state()
# Store session info in context
await ctx.store.set("index_name", index_name)
await ctx.store.set("session_id", session_id)
await ctx.store.set("conversation_history", [])
messages = initial_state.conversation_history
# Create cache key for chat engine
cache_key = f"{index_name}_{session_id}"
for item in messages:
ctx.write_event_to_stream(AppendChatMessage(message=item))
# Initialize chat engine if not exists
if cache_key not in self.chat_engines:
logger.info(f"Initializing chat engine {cache_key}")
# Connect to LlamaCloud Index
index = LlamaCloudIndex(
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
async_httpx_client=custom_client,
)
# Create chat engine with memory
memory = ChatMemoryBuffer.from_defaults(token_limit=3900)
self.chat_engines[cache_key] = index.as_chat_engine(
chat_mode=ChatMode.CONTEXT,
memory=memory,
llm=Settings.llm,
context_prompt=(
"You are a helpful assistant that answers questions based on the provided documents. "
"Always cite specific information from the documents when answering. "
"If you cannot find the answer in the documents, say so clearly."
),
verbose=False,
retriever_mode=RetrievalMode.CHUNKS,
)
# Request first user input
return InputRequiredEvent(
prefix="Chat initialized. Ask a question (or type 'exit' to quit): "
)
if ev.conversation_history:
async with ctx.store.edit_state() as state:
state.conversation_history.extend(ev.conversation_history)
except Exception as e:
return StopEvent(
result={
"success": False,
"error": f"Failed to initialize chat: {str(e)}",
}
logger.error(f"Error initializing chat: {str(e)}", exc_info=True)
ctx.write_event_to_stream(
ErrorEvent(error=f"Failed to initialize chat: {str(e)}")
)
return InputRequiredEvent()
@step
async def get_conversation_history(
self, ev: QueryConversationHistoryEvent, ctx: Context[ChatWorkflowState]
) -> None:
"""Get the conversation history from the database"""
hist = (await ctx.store.get_state()).conversation_history
for item in hist:
ctx.write_event_to_stream(AppendChatMessage(message=item))
@step
async def process_user_response(
self, ev: HumanResponseEvent, ctx: Context
) -> InputRequiredEvent | HumanResponseEvent | StopEvent | None:
self, ev: HumanResponseEvent, ctx: Context[ChatWorkflowState]
) -> InputRequiredEvent | HumanResponseEvent | None:
"""Process user input and generate response"""
try:
logger.info(f"Processing user response {ev.response}")
user_input = ev.response.strip()
initial_state = await ctx.store.get_state()
conversation_history = initial_state.conversation_history
index_name = initial_state.index_name
if not index_name:
raise ValueError("Index name not found in context")
logger.info(f"User input: {user_input}")
# Check for exit command
if user_input.lower() == "exit":
logger.info("User input is exit")
conversation_history = await ctx.store.get(
"conversation_history", default=[]
)
return StopEvent(
result={
"success": True,
@@ -268,72 +285,52 @@ class ChatWorkflow(Workflow):
}
)
# Get session info from context
index_name = await ctx.store.get("index_name")
session_id = await ctx.store.get("session_id")
cache_key = f"{index_name}_{session_id}"
chat_engine = get_chat_engine(index_name)
# Get chat engine
chat_engine = self.chat_engines[cache_key]
# Process query with chat engine (streaming)
stream_response = await chat_engine.astream_chat(user_input)
stream_response = await chat_engine.astream_chat(
user_input, chat_history=initial_state.chat_messages()
)
full_text = ""
# Emit streaming deltas to the event stream
async for token in stream_response.async_response_gen():
full_text += token
ctx.write_event_to_stream(ChatDeltaEvent(delta=token))
await asyncio.sleep(
0
) # Temp workaround. Some sort of bug in the server drops events without flushing the event loop
# Extract source nodes for citations
sources = []
if hasattr(stream_response, "source_nodes"):
if stream_response.source_nodes:
for node in stream_response.source_nodes:
sources.append(
{
"text": node.text[:200] + "..."
if len(node.text) > 200
SourceMessage(
text=node.text[:197] + "..."
if len(node.text) >= 200
else node.text,
"score": node.score if hasattr(node, "score") else None,
"metadata": node.metadata
if hasattr(node, "metadata")
else {},
}
score=float(node.score) if node.score else 0.0,
metadata=node.metadata,
)
)
# Update conversation history
conversation_history = await ctx.store.get(
"conversation_history", default=[]
)
conversation_history.append(
{
"query": user_input,
"response": full_text.strip()
if full_text
else str(stream_response),
"sources": sources,
}
)
await ctx.store.set("conversation_history", conversation_history)
# After streaming completes, emit a summary response event to stream for frontend/main printing
ctx.write_event_to_stream(
ChatResponseEvent(
response=full_text.strip() if full_text else str(stream_response),
sources=sources,
query=user_input,
assistant_response = ConversationMessage(
role="assistant", text=full_text, sources=sources
)
ctx.write_event_to_stream(AppendChatMessage(message=assistant_response))
async with ctx.store.edit_state() as state:
state.conversation_history.extend(
[
ConversationMessage(role="user", text=user_input),
assistant_response,
]
)
)
# Prompt for next input
return InputRequiredEvent(
prefix="\nAsk another question (or type 'exit' to quit): "
)
except Exception as e:
return StopEvent(
result={"success": False, "error": f"Error processing query: {str(e)}"}
)
logger.error(f"Error processing query: {str(e)}", exc_info=True)
ctx.write_event_to_stream(ErrorEvent(error=str(e)))
return InputRequiredEvent()
upload = DocumentUploadWorkflow(timeout=None)
+2 -2
View File
@@ -1,5 +1,5 @@
[project]
name = "test_proj"
name = "test-proj"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
@@ -7,7 +7,7 @@ authors = []
requires-python = ">=3.12"
dependencies = [
"llama-index-workflows>=2.2.0,<3.0.0",
"llama-cloud-services>=0.6.68",
"llama-cloud-services>=0.6.69",
"llama-index-core>=0.14.0",
"llama-index-llms-openai>=0.5.6",
"llama-index-embeddings-openai>=0.5.1",
+19 -5
View File
@@ -3,7 +3,8 @@ import os
import httpx
from llama_cloud.client import AsyncLlamaCloud
from llama_cloud_services import LlamaParse
from llama_cloud_services import LlamaCloudIndex, LlamaParse
from llama_cloud_services.parse import ResultType
# deployed agents may infer their name from the deployment name
# Note: Make sure that an agent deployment with this name actually exists
@@ -18,7 +19,8 @@ LLAMA_CLOUD_PROJECT_ID = os.getenv("LLAMA_DEPLOY_PROJECT_ID")
INDEX_NAME = "document_qa_index"
def get_custom_client() -> httpx.AsyncClient:
@functools.cache
def get_base_cloud_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=60,
headers={"Project-Id": LLAMA_CLOUD_PROJECT_ID}
@@ -32,7 +34,7 @@ def get_llama_cloud_client() -> AsyncLlamaCloud:
return AsyncLlamaCloud(
base_url=LLAMA_CLOUD_BASE_URL,
token=LLAMA_CLOUD_API_KEY,
httpx_client=get_custom_client(),
httpx_client=get_base_cloud_client(),
)
@@ -45,8 +47,20 @@ def get_llama_parse_client() -> LlamaParse:
adaptive_long_table=True,
outlined_table_extraction=True,
output_tables_as_HTML=True,
result_type="markdown",
result_type=ResultType.MD,
api_key=LLAMA_CLOUD_API_KEY,
project_id=LLAMA_CLOUD_PROJECT_ID,
custom_client=get_custom_client(),
custom_client=get_base_cloud_client(),
)
@functools.lru_cache(maxsize=None)
def get_index(index_name: str) -> LlamaCloudIndex:
return LlamaCloudIndex.create_index(
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
show_progress=True,
custom_client=get_base_cloud_client(),
)
+129 -132
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
from datetime import datetime
import logging
import os
import tempfile
from typing import Any, Literal
import httpx
from dotenv import load_dotenv
from llama_cloud.types import RetrievalMode
from llama_index.core import Settings
from llama_index.core.chat_engine.types import BaseChatEngine, ChatMode
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.chat_engine.types import (
BaseChatEngine,
ChatMode,
)
from llama_index.core.llms import ChatMessage
import asyncio
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_cloud_services import LlamaCloudIndex
from pydantic import BaseModel, Field
from workflows import Workflow, step, Context
from workflows.events import (
StartEvent,
@@ -22,17 +27,12 @@ from workflows.events import (
from workflows.retry_policy import ConstantDelayRetryPolicy
from .clients import (
LLAMA_CLOUD_API_KEY,
LLAMA_CLOUD_BASE_URL,
get_custom_client,
get_index,
get_llama_cloud_client,
get_llama_parse_client,
LLAMA_CLOUD_PROJECT_ID,
)
load_dotenv()
logger = logging.getLogger(__name__)
@@ -53,15 +53,13 @@ class FileDownloadedEvent(Event):
class ChatEvent(StartEvent):
index_name: str
session_id: str
conversation_history: list[ConversationMessage] = Field(default_factory=list)
# Configure LLM and embedding model
Settings.llm = OpenAI(model="gpt-4", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
custom_client = get_custom_client()
class DocumentUploadWorkflow(Workflow):
"""Workflow to upload and index documents using LlamaParse and LlamaCloud Index"""
@@ -131,15 +129,7 @@ class DocumentUploadWorkflow(Workflow):
documents = result.get_text_documents()
# Create or connect to LlamaCloud Index
index = LlamaCloudIndex.create_index(
documents=documents,
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
show_progress=True,
custom_client=custom_client,
)
index = get_index(index_name)
# Insert documents to index
logger.info(f"Inserting {len(documents)} documents to {index_name}")
@@ -158,18 +148,14 @@ class DocumentUploadWorkflow(Workflow):
)
except Exception as e:
logger.error(e.stack_trace)
return StopEvent(
result={"success": False, "error": str(e), "stack_trace": e.stack_trace}
)
logger.error(f"Error parsing document {ev.file_id}: {e}", exc_info=True)
return StopEvent(result={"success": False, "error": str(e)})
class ChatResponseEvent(Event):
"""Event emitted when chat engine generates a response"""
class AppendChatMessage(Event):
"""Event emitted when chat engine appends a message to the conversation history"""
response: str
sources: list
query: str
message: ConversationMessage
class ChatDeltaEvent(Event):
@@ -178,88 +164,119 @@ class ChatDeltaEvent(Event):
delta: str
class QueryConversationHistoryEvent(HumanResponseEvent):
"""Client can call this to trigger replaying AppendChatMessage events"""
pass
class ErrorEvent(Event):
"""Event emitted when an error occurs"""
error: str
class ChatWorkflowState(BaseModel):
index_name: str | None = None
conversation_history: list[ConversationMessage] = Field(default_factory=list)
def chat_messages(self) -> list[ChatMessage]:
return [
ChatMessage(role=message.role, content=message.text)
for message in self.conversation_history
]
class SourceMessage(BaseModel):
text: str
score: float
metadata: dict[str, Any]
class ConversationMessage(BaseModel):
"""
Mostly just a wrapper for a ChatMessage with extra context for UI. Includes a timestamp and source references.
"""
role: Literal["user", "assistant"]
text: str
sources: list[SourceMessage] = Field(default_factory=list)
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
def get_chat_engine(index_name: str) -> BaseChatEngine:
index = get_index(index_name)
return index.as_chat_engine(
chat_mode=ChatMode.CONTEXT,
llm=Settings.llm,
context_prompt=(
"You are a helpful assistant that answers questions based on the provided documents. "
"Always cite specific information from the documents when answering. "
"If you cannot find the answer in the documents, say so clearly."
),
)
class ChatWorkflow(Workflow):
"""Workflow to handle continuous chat queries against indexed documents"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.chat_engines: dict[
str, BaseChatEngine
] = {} # Cache chat engines per index
@step
async def initialize_chat(self, ev: ChatEvent, ctx: Context) -> InputRequiredEvent:
async def initialize_chat(
self, ev: ChatEvent, ctx: Context[ChatWorkflowState]
) -> InputRequiredEvent | StopEvent | None:
"""Initialize the chat session and request first input"""
try:
logger.info(f"Initializing chat {ev.index_name}")
index_name = ev.index_name
session_id = ev.session_id
initial_state = await ctx.store.get_state()
# Store session info in context
await ctx.store.set("index_name", index_name)
await ctx.store.set("session_id", session_id)
await ctx.store.set("conversation_history", [])
messages = initial_state.conversation_history
# Create cache key for chat engine
cache_key = f"{index_name}_{session_id}"
for item in messages:
ctx.write_event_to_stream(AppendChatMessage(message=item))
# Initialize chat engine if not exists
if cache_key not in self.chat_engines:
logger.info(f"Initializing chat engine {cache_key}")
# Connect to LlamaCloud Index
index = LlamaCloudIndex(
name=index_name,
project_id=LLAMA_CLOUD_PROJECT_ID,
api_key=LLAMA_CLOUD_API_KEY,
base_url=LLAMA_CLOUD_BASE_URL,
async_httpx_client=custom_client,
)
# Create chat engine with memory
memory = ChatMemoryBuffer.from_defaults(token_limit=3900)
self.chat_engines[cache_key] = index.as_chat_engine(
chat_mode=ChatMode.CONTEXT,
memory=memory,
llm=Settings.llm,
context_prompt=(
"You are a helpful assistant that answers questions based on the provided documents. "
"Always cite specific information from the documents when answering. "
"If you cannot find the answer in the documents, say so clearly."
),
verbose=False,
retriever_mode=RetrievalMode.CHUNKS,
)
# Request first user input
return InputRequiredEvent(
prefix="Chat initialized. Ask a question (or type 'exit' to quit): "
)
if ev.conversation_history:
async with ctx.store.edit_state() as state:
state.conversation_history.extend(ev.conversation_history)
except Exception as e:
return StopEvent(
result={
"success": False,
"error": f"Failed to initialize chat: {str(e)}",
}
logger.error(f"Error initializing chat: {str(e)}", exc_info=True)
ctx.write_event_to_stream(
ErrorEvent(error=f"Failed to initialize chat: {str(e)}")
)
return InputRequiredEvent()
@step
async def get_conversation_history(
self, ev: QueryConversationHistoryEvent, ctx: Context[ChatWorkflowState]
) -> None:
"""Get the conversation history from the database"""
hist = (await ctx.store.get_state()).conversation_history
for item in hist:
ctx.write_event_to_stream(AppendChatMessage(message=item))
@step
async def process_user_response(
self, ev: HumanResponseEvent, ctx: Context
) -> InputRequiredEvent | HumanResponseEvent | StopEvent | None:
self, ev: HumanResponseEvent, ctx: Context[ChatWorkflowState]
) -> InputRequiredEvent | HumanResponseEvent | None:
"""Process user input and generate response"""
try:
logger.info(f"Processing user response {ev.response}")
user_input = ev.response.strip()
initial_state = await ctx.store.get_state()
conversation_history = initial_state.conversation_history
index_name = initial_state.index_name
if not index_name:
raise ValueError("Index name not found in context")
logger.info(f"User input: {user_input}")
# Check for exit command
if user_input.lower() == "exit":
logger.info("User input is exit")
conversation_history = await ctx.store.get(
"conversation_history", default=[]
)
return StopEvent(
result={
"success": True,
@@ -268,72 +285,52 @@ class ChatWorkflow(Workflow):
}
)
# Get session info from context
index_name = await ctx.store.get("index_name")
session_id = await ctx.store.get("session_id")
cache_key = f"{index_name}_{session_id}"
chat_engine = get_chat_engine(index_name)
# Get chat engine
chat_engine = self.chat_engines[cache_key]
# Process query with chat engine (streaming)
stream_response = await chat_engine.astream_chat(user_input)
stream_response = await chat_engine.astream_chat(
user_input, chat_history=initial_state.chat_messages()
)
full_text = ""
# Emit streaming deltas to the event stream
async for token in stream_response.async_response_gen():
full_text += token
ctx.write_event_to_stream(ChatDeltaEvent(delta=token))
await asyncio.sleep(
0
) # Temp workaround. Some sort of bug in the server drops events without flushing the event loop
# Extract source nodes for citations
sources = []
if hasattr(stream_response, "source_nodes"):
if stream_response.source_nodes:
for node in stream_response.source_nodes:
sources.append(
{
"text": node.text[:200] + "..."
if len(node.text) > 200
SourceMessage(
text=node.text[:197] + "..."
if len(node.text) >= 200
else node.text,
"score": node.score if hasattr(node, "score") else None,
"metadata": node.metadata
if hasattr(node, "metadata")
else {},
}
score=float(node.score) if node.score else 0.0,
metadata=node.metadata,
)
)
# Update conversation history
conversation_history = await ctx.store.get(
"conversation_history", default=[]
)
conversation_history.append(
{
"query": user_input,
"response": full_text.strip()
if full_text
else str(stream_response),
"sources": sources,
}
)
await ctx.store.set("conversation_history", conversation_history)
# After streaming completes, emit a summary response event to stream for frontend/main printing
ctx.write_event_to_stream(
ChatResponseEvent(
response=full_text.strip() if full_text else str(stream_response),
sources=sources,
query=user_input,
assistant_response = ConversationMessage(
role="assistant", text=full_text, sources=sources
)
ctx.write_event_to_stream(AppendChatMessage(message=assistant_response))
async with ctx.store.edit_state() as state:
state.conversation_history.extend(
[
ConversationMessage(role="user", text=user_input),
assistant_response,
]
)
)
# Prompt for next input
return InputRequiredEvent(
prefix="\nAsk another question (or type 'exit' to quit): "
)
except Exception as e:
return StopEvent(
result={"success": False, "error": f"Error processing query: {str(e)}"}
)
logger.error(f"Error processing query: {str(e)}", exc_info=True)
ctx.write_event_to_stream(ErrorEvent(error=str(e)))
return InputRequiredEvent()
upload = DocumentUploadWorkflow(timeout=None)
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Start UI</title>
<script>
// Prevent dark mode flash - respect system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -17,6 +17,7 @@
"@llamaindex/ui": "^2.1.1",
"@llamaindex/workflows-client": "^1.2.0",
"@radix-ui/themes": "^3.2.1",
"idb": "^8.0.3",
"llama-cloud-services": "^0.3.6",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
+21
View File
@@ -2,8 +2,29 @@ import { ApiProvider } from "@llamaindex/ui";
import Home from "./pages/Home";
import { Theme } from "@radix-ui/themes";
import { clients } from "@/libs/clients";
import { useEffect } from "react";
export default function App() {
// Apply dark mode based on system preference
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateDarkMode = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
// Set initial state
updateDarkMode(mediaQuery);
// Listen for changes
mediaQuery.addEventListener("change", updateDarkMode);
return () => mediaQuery.removeEventListener("change", updateDarkMode);
}, []);
return (
<Theme>
<ApiProvider clients={clients}>
+174 -431
View File
@@ -1,167 +1,30 @@
// This is a temporary chatbot component that is used to test the chatbot functionality.
// LlamaIndex will replace it with better chatbot component.
import { useState, useRef, useEffect, FormEvent, KeyboardEvent } from "react";
import {
Send,
Loader2,
Bot,
User,
MessageSquare,
Trash2,
RefreshCw,
} from "lucide-react";
import {
Button,
Input,
ScrollArea,
Card,
CardContent,
cn,
useWorkflowRun,
useWorkflowHandler,
} from "@llamaindex/ui";
import { AGENT_NAME } from "../libs/config";
import { toHumanResponseRawEvent } from "@/libs/utils";
import { useChatbot } from "@/libs/useChatbot";
import { Button, cn, ScrollArea, Textarea } from "@llamaindex/ui";
import { Bot, Loader2, RefreshCw, Send, User } from "lucide-react";
import { FormEvent, KeyboardEvent, useEffect, useRef } from "react";
type Role = "user" | "assistant";
interface Message {
id: string;
role: Role;
content: string;
timestamp: Date;
error?: boolean;
}
export default function ChatBot() {
const { runWorkflow } = useWorkflowRun();
export default function ChatBot({
handlerId,
onHandlerCreated,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [handlerId, setHandlerId] = useState<string | null>(null);
const lastProcessedEventIndexRef = useRef<number>(0);
const [canSend, setCanSend] = useState<boolean>(false);
const streamingMessageIndexRef = useRef<number | null>(null);
// Deployment + auth setup
const deployment = AGENT_NAME || "document-qa";
const platformToken = (import.meta as any).env?.VITE_LLAMA_CLOUD_API_KEY as
| string
| undefined;
const projectId = (import.meta as any).env?.VITE_LLAMA_DEPLOY_PROJECT_ID as
| string
| undefined;
const defaultIndexName =
(import.meta as any).env?.VITE_DEFAULT_INDEX_NAME || "document_qa_index";
const sessionIdRef = useRef<string>(
`chat-${Math.random().toString(36).slice(2)}-${Date.now()}`,
);
const chatbot = useChatbot({
handlerId,
onHandlerCreated,
focusInput: () => {
inputRef.current?.focus();
},
});
// UI text defaults
const title = "AI Document Assistant";
const placeholder = "Ask me anything about your documents...";
const welcomeMessage =
"Welcome! 👋 Upload a document with the control above, then ask questions here.";
// Helper functions for message management
const appendMessage = (role: Role, msg: string): void => {
setMessages((prev) => {
const id = `${role}-stream-${Date.now()}`;
const idx = prev.length;
streamingMessageIndexRef.current = idx;
return [
...prev,
{
id,
role,
content: msg,
timestamp: new Date(),
},
];
});
};
const updateMessage = (index: number, message: string) => {
setMessages((prev) => {
if (index < 0 || index >= prev.length) return prev;
const copy = [...prev];
const existing = copy[index];
copy[index] = { ...existing, content: message };
return copy;
});
};
// Initialize with welcome message
useEffect(() => {
if (messages.length === 0) {
const welcomeMsg: Message = {
id: "welcome",
role: "assistant",
content: welcomeMessage,
timestamp: new Date(),
};
setMessages([welcomeMsg]);
}
}, []);
// Create chat task on init
useEffect(() => {
(async () => {
if (!handlerId) {
const handler = await runWorkflow("chat", {
index_name: defaultIndexName,
session_id: sessionIdRef.current,
});
setHandlerId(handler.handler_id);
}
})();
}, []);
// Subscribe to task/events using hook (auto stream when handler exists)
const { events } = useWorkflowHandler(handlerId ?? "", Boolean(handlerId));
// Process streamed events into messages
useEffect(() => {
if (!events || events.length === 0) return;
let startIdx = lastProcessedEventIndexRef.current;
if (startIdx < 0) startIdx = 0;
if (startIdx >= events.length) return;
for (let i = startIdx; i < events.length; i++) {
const ev: any = events[i];
const type = ev?.type as string | undefined;
const rawData = ev?.data as any;
if (!type) continue;
const data = (rawData && (rawData._data ?? rawData)) as any;
if (type.includes("ChatDeltaEvent")) {
const delta: string = data?.delta ?? "";
if (!delta) continue;
if (streamingMessageIndexRef.current === null) {
appendMessage("assistant", delta);
} else {
const idx = streamingMessageIndexRef.current;
const current = messages[idx!]?.content ?? "";
if (current === "Thinking...") {
updateMessage(idx!, delta);
} else {
updateMessage(idx!, current + delta);
}
}
} else if (type.includes("ChatResponseEvent")) {
// finalize current stream
streamingMessageIndexRef.current = null;
} else if (type.includes("InputRequiredEvent")) {
// ready for next user input; enable send
setCanSend(true);
setIsLoading(false);
inputRef.current?.focus();
} else if (type.includes("StopEvent")) {
// finished; no summary bubble needed (chat response already streamed)
}
}
lastProcessedEventIndexRef.current = events.length;
}, [events, messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -169,324 +32,204 @@ export default function ChatBot() {
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [chatbot.messages]);
// No manual SSE cleanup needed
const getCommonHeaders = () => ({
...(platformToken ? { authorization: `Bearer ${platformToken}` } : {}),
...(projectId ? { "Project-Id": projectId } : {}),
});
const startChatIfNeeded = async (): Promise<string> => {
if (handlerId) return handlerId;
const handler = await runWorkflow("chat", {
index_name: defaultIndexName,
session_id: sessionIdRef.current,
});
setHandlerId(handler.handler_id);
return handler.handler_id;
};
// Removed manual SSE ensureEventStream; hook handles streaming
// Reset textarea height when input is cleared
useEffect(() => {
if (!chatbot.input && inputRef.current) {
inputRef.current.style.height = "48px"; // Reset to initial height
}
}, [chatbot.input]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading || !canSend) return;
// Add user message
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: trimmedInput,
timestamp: new Date(),
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsLoading(true);
setCanSend(false);
// Immediately create an assistant placeholder to avoid visual gap before deltas
if (streamingMessageIndexRef.current === null) {
appendMessage("assistant", "Thinking...");
}
try {
// Ensure chat handler exists (created on init)
const hid = await startChatIfNeeded();
// Send user input as HumanResponseEvent
const postRes = await fetch(`/deployments/${deployment}/events/${hid}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getCommonHeaders(),
},
body: JSON.stringify({
event: JSON.stringify(toHumanResponseRawEvent(trimmedInput)),
}),
});
if (!postRes.ok) {
throw new Error(
`Failed to send message: ${postRes.status} ${postRes.statusText}`,
);
}
// The assistant reply will be streamed by useWorkflowTask and appended incrementally
} catch (err) {
console.error("Chat error:", err);
// Add error message
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, I encountered an error: ${err instanceof Error ? err.message : "Unknown error"}. Please try again.`,
timestamp: new Date(),
error: true,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
// Focus back on input
inputRef.current?.focus();
}
await chatbot.submit();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
// Allow Shift+Enter to create new line (default behavior)
};
const clearChat = () => {
setMessages([
{
id: "welcome",
role: "assistant" as const,
content: welcomeMessage,
timestamp: new Date(),
},
]);
setInput("");
inputRef.current?.focus();
const adjustTextareaHeight = (textarea: HTMLTextAreaElement) => {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 128) + "px"; // 128px = max-h-32
};
const retryLastMessage = () => {
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
if (lastUserMessage) {
// Remove the last assistant message if it was an error
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "assistant" && lastMessage.error) {
setMessages((prev) => prev.slice(0, -1));
}
setInput(lastUserMessage.content);
inputRef.current?.focus();
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
chatbot.setInput(e.target.value);
adjustTextareaHeight(e.target);
};
return (
<div
className={cn(
"flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg shadow-lg",
)}
>
{/* Header */}
<div className="px-4 py-3 border-b dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h3 className="font-semibold text-gray-900 dark:text-white">
{title}
</h3>
{isLoading && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Thinking...
</span>
)}
</div>
<div className="flex items-center gap-2">
{messages.some((m) => m.error) && (
<button
onClick={retryLastMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Retry last message"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{messages.length > 0 && (
<button
onClick={clearChat}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Clear chat"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<div className="flex flex-col h-full bg-background">
{/* Simplified header - only show retry button when needed */}
{chatbot.messages.some((m) => m.error) && (
<div className="flex justify-center">
<div className="w-full max-w-4xl px-4 py-2 border-b bg-muted/30">
<button
onClick={chatbot.retryLastMessage}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
title="Retry last message"
>
<RefreshCw className="w-4 h-4" />
Retry last message
</button>
</div>
</div>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[300px]">
<div className="text-center">
<Bot className="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 dark:text-gray-400 mb-2">
No messages yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
Start a conversation!
</p>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6">
{chatbot.messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[400px]">
<div className="text-center">
<Bot className="w-16 h-16 text-muted-foreground/50 mx-auto mb-4" />
<p className="text-lg text-foreground mb-2">
Welcome! 👋 Upload a document with the control above, then ask
questions here.
</p>
<p className="text-sm text-muted-foreground">
Start by uploading a document to begin your conversation
</p>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role !== "user" && (
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
message.error
? "bg-red-100 dark:bg-red-900"
: "bg-blue-100 dark:bg-blue-900",
)}
>
<Bot
className={cn(
"w-5 h-5",
message.error
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
/>
</div>
)}
) : (
<div className="space-y-6">
{chatbot.messages.map((message, i) => (
<div
key={i}
className={cn(
"max-w-[70%]",
message.role === "user" ? "order-1" : "order-2",
"flex gap-4",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
<Card
{message.role !== "user" && (
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-1",
message.error
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
<Bot className="w-5 h-5" />
</div>
)}
<div
className={cn(
"py-0",
message.role === "user"
? "bg-blue-600 text-white border-blue-600"
: message.error
? "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
: "bg-gray-50 dark:bg-gray-700",
"max-w-[75%]",
message.role === "user" ? "order-1" : "order-2",
)}
>
<CardContent className="p-3">
<div
className={cn(
"rounded-2xl px-4 py-3",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.error
? "bg-destructive/5 border border-destructive/20"
: "bg-muted",
)}
>
{message.isPartial && !message.content ? (
<div className="m-2">
<LoadingDots />
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
)}
<p
className={cn(
"whitespace-pre-wrap text-sm",
message.error && "text-red-700 dark:text-red-400",
)}
>
{message.content}
</p>
<p
className={cn(
"text-xs mt-1 opacity-70",
"text-xs mt-2 opacity-60",
message.role === "user"
? "text-blue-100"
? "text-primary-foreground"
: message.error
? "text-red-500 dark:text-red-400"
: "text-gray-500 dark:text-gray-400",
? "text-destructive"
: "text-muted-foreground",
)}
>
{message.timestamp.toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
{message.role === "user" && (
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 order-2">
<User className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<Bot className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<Card className="bg-gray-50 dark:bg-gray-700 py-0">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
></span>
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
></span>
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
></span>
</div>
</div>
</CardContent>
</Card>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{message.role === "user" && (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 order-2 mt-1">
<User className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
</ScrollArea>
{/* Input */}
<div className="border-t dark:border-gray-700 p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
className="flex-1"
autoFocus
/>
<Button
type="submit"
disabled={!canSend || isLoading || !input.trim()}
size="icon"
title="Send message"
>
{!canSend || isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
</form>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Press Enter to send Shift+Enter for new line
</p>
<div className="border-t bg-background">
<div className="max-w-4xl mx-auto p-6">
<form onSubmit={handleSubmit} className="flex gap-3">
<Textarea
ref={inputRef}
value={chatbot.input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={chatbot.isLoading}
className="flex-1 min-h-12 max-h-32 rounded-xl border-2 focus:border-primary resize-none overflow-hidden"
autoFocus
style={{ height: "48px" }} // Initial height (min-h-12)
/>
<Button
type="submit"
disabled={
!chatbot.canSend || chatbot.isLoading || !chatbot.input.trim()
}
size="icon"
title="Send message"
className="h-12 w-12 rounded-xl"
>
{!chatbot.canSend || chatbot.isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</Button>
</form>
<p className="text-xs text-muted-foreground mt-3 text-center">
Press Enter to send Shift+Enter for new line
</p>
</div>
</div>
</div>
);
}
const LoadingDots = () => {
return (
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
></span>
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
></span>
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
></span>
</div>
</div>
);
};
+181
View File
@@ -0,0 +1,181 @@
import { Plus, X, ChevronLeft, ChevronRight } from "lucide-react";
import { Button, ScrollArea, cn } from "@llamaindex/ui";
import { ChatHistory, UseChatHistory } from "../libs/useChatHistory";
import { useState } from "react";
interface SidebarProps {
className?: string;
chatHistory: UseChatHistory;
}
export default function Sidebar({ className, chatHistory }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const {
loading,
chats,
selectedChatId,
setSelectedChatId,
deleteChat,
createNewChat,
} = chatHistory;
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const timeString = date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
if (isToday) {
return timeString;
} else {
const dateString = date.toLocaleDateString();
return `${dateString} ${timeString}`;
}
};
const handleChatSelect = (chat: ChatHistory): void => {
setSelectedChatId(chat.handlerId);
};
const handleDeleteChat = (e: React.MouseEvent, handlerId: string): void => {
e.stopPropagation();
deleteChat(handlerId);
};
return (
<div
className={cn(
"flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300",
isCollapsed ? "w-16" : "w-[280px]",
className,
)}
>
{/* Header */}
<div className="px-4 py-4 border-b border-sidebar-border">
<div className="flex items-center justify-between">
{!isCollapsed && (
<h3 className="text-sm font-medium text-sidebar-foreground">
Chats
</h3>
)}
<div className="flex items-center gap-1">
{!isCollapsed && (
<Button
size="sm"
variant="ghost"
onClick={createNewChat}
className="h-8 w-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title="New Chat"
>
<Plus className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => setIsCollapsed(!isCollapsed)}
className="h-8 w-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
{/* Chat List */}
<ScrollArea className="flex-1">
{isCollapsed ? (
// Collapsed state - show dots for each chat
<div className="p-2 space-y-2">
{!loading && chats.length > 0 && (
<>
{chats.map((chat) => (
<div
key={chat.handlerId}
className={cn(
"w-8 h-8 rounded-lg cursor-pointer transition-colors flex items-center justify-center",
selectedChatId === chat.handlerId
? "bg-sidebar-primary"
: "hover:bg-sidebar-accent",
)}
onClick={() => handleChatSelect(chat)}
title={formatTimestamp(chat.timestamp)}
>
<div className="w-2 h-2 rounded-full bg-current opacity-70" />
</div>
))}
<Button
size="sm"
variant="ghost"
onClick={createNewChat}
className="w-8 h-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title="New Chat"
>
<Plus className="w-4 h-4" />
</Button>
</>
)}
</div>
) : (
// Expanded state
<>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">Loading...</div>
</div>
) : chats.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
No chats yet
</div>
</div>
) : (
<div className="p-2 space-y-1">
{chats.map((chat) => (
<div
key={chat.handlerId}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer transition-colors",
selectedChatId === chat.handlerId
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
onClick={() => handleChatSelect(chat)}
>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">
{formatTimestamp(chat.timestamp)}
</div>
</div>
<button
onClick={(e) => handleDeleteChat(e, chat.handlerId)}
className={cn(
"ml-2 p-1 rounded transition-colors",
selectedChatId === chat.handlerId
? "text-sidebar-primary-foreground/70 hover:text-sidebar-primary-foreground hover:bg-sidebar-primary-foreground/10"
: "text-muted-foreground hover:text-destructive",
)}
aria-label="Delete chat"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</ScrollArea>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { WorkflowEvent } from "@llamaindex/ui";
export function createQueryConversationHistoryEvent(): WorkflowEvent {
return {
data: {},
type: "test_proj.qa_workflows.QueryConversationHistoryEvent",
};
}
export function createHumanResponseEvent(response: string): WorkflowEvent {
return {
data: { _data: { response } },
type: "test_proj.qa_workflows.HumanResponseEvent",
};
}
+185
View File
@@ -0,0 +1,185 @@
import { IDBPDatabase, openDB } from "idb";
import { useEffect, useState } from "react";
export interface ChatHistory {
handlerId: string;
timestamp: string;
}
export interface UseChatHistory {
loading: boolean;
addChat(handlerId: string): void;
deleteChat(handlerId: string): void;
chats: ChatHistory[];
selectedChatId: string | null;
setSelectedChatId(handlerId: string): void;
createNewChat(): void;
// forces a new chat
chatCounter: number;
}
const DB_NAME = "chat-history";
const DB_VERSION = 1;
const STORE_NAME = "chats";
/**
* Hook that tracks workflow handler ids, to use as markers of a chat conversation that can be reloaded.
* Stores chats in IndexedDB
* @returns
*/
export function useChatHistory(): UseChatHistory {
const [loading, setLoading] = useState(true);
const [chatHistory, setChatHistory] = useState<ChatHistory[]>([]);
const [selectedChatHandlerId, setSelectedChatHandlerId] = useState<
string | null
>(null);
const [db, setDb] = useState<IDBPDatabase<unknown> | null>(null);
const [chatCounter, setChatCounter] = useState(0);
// Initialize database
useEffect(() => {
let thisDb: IDBPDatabase<unknown> | null = null;
const initDb = async () => {
try {
thisDb = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "handlerId",
});
store.createIndex("timestamp", "timestamp");
}
},
});
setDb(thisDb);
} catch (error) {
console.error("Failed to initialize database:", error);
setLoading(false);
}
};
initDb();
return () => {
thisDb?.close();
};
}, []);
// Load chat history when database is ready
useEffect(() => {
if (!db) return;
const loadChats = async () => {
try {
setLoading(true);
const chats = await getChatsFromDb();
setChatHistory(chats);
// Initialize selectedChat to the latest chat (first in sorted array)
if (chats.length > 0 && !selectedChatHandlerId) {
setSelectedChatHandlerId(chats[0].handlerId);
}
} catch (error) {
console.error("Failed to load chat history:", error);
} finally {
setLoading(false);
}
};
loadChats();
}, [db]);
const getChatsFromDb = async (): Promise<ChatHistory[]> => {
if (!db) return [];
try {
const transaction = db.transaction(STORE_NAME, "readonly");
const store = transaction.objectStore(STORE_NAME);
const index = store.index("timestamp");
const chats = await index.getAll();
// Sort by timestamp descending (most recent first)
return chats.sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
} catch (error) {
console.error("Failed to get chats from database:", error);
return [];
}
};
const addChat = async (handlerId: string): Promise<void> => {
if (!db) return;
try {
const chat: ChatHistory = {
handlerId,
timestamp: new Date().toISOString(),
};
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
await store.put(chat);
// Update local state
setChatHistory((prev) => [
chat,
...prev.filter((c) => c.handlerId !== handlerId),
]);
// Set as selected chat if it's the first chat or if no chat is currently selected
if (!selectedChatHandlerId) {
setSelectedChatHandlerId(chat.handlerId);
}
} catch (error) {
console.error("Failed to add chat to database:", error);
}
};
const deleteChat = async (handlerId: string): Promise<void> => {
if (!db) return;
try {
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
await store.delete(handlerId);
// Update local state
setChatHistory((prev) => prev.filter((c) => c.handlerId !== handlerId));
// If the deleted chat was selected, select the next available chat or clear selection
if (selectedChatHandlerId === handlerId) {
const remainingChats = chatHistory.filter(
(c) => c.handlerId !== handlerId,
);
if (remainingChats.length > 0) {
setSelectedChatHandlerId(remainingChats[0].handlerId);
setChatCounter((prev) => prev + 1);
} else {
setSelectedChatHandlerId(null);
setChatCounter((prev) => prev + 1);
}
}
} catch (error) {
console.error("Failed to delete chat from database:", error);
}
};
const createNewChat = (): void => {
setSelectedChatHandlerId(null);
setChatCounter((prev) => prev + 1);
};
return {
loading,
addChat,
chats: chatHistory,
selectedChatId: selectedChatHandlerId,
setSelectedChatId: setSelectedChatHandlerId,
deleteChat,
createNewChat,
chatCounter,
};
}
@@ -0,0 +1,69 @@
import {
useWorkflowHandler,
useWorkflowRun,
useHandlerStore,
} from "@llamaindex/ui";
import { useEffect, useRef, useState } from "react";
import { INDEX_NAME } from "./config";
import { createQueryConversationHistoryEvent } from "./events";
/**
* Creates a new chat conversation if no handlerId is provided
*/
export function useChatWorkflowHandler({
handlerId,
onHandlerCreated,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
}): ReturnType<typeof useWorkflowHandler> {
const create = useWorkflowRun();
const isQueryingWorkflow = useRef(false);
const [thisHandlerId, setThisHandlerId] = useState<string | undefined>(
handlerId,
);
const workflowHandler = useWorkflowHandler(thisHandlerId ?? "", true);
const store = useHandlerStore();
const createHandler = async () => {
if (isQueryingWorkflow.current) return;
isQueryingWorkflow.current = true;
try {
const handler = await create.runWorkflow("chat", {
index_name: INDEX_NAME,
});
setThisHandlerId(handler.handler_id);
onHandlerCreated?.(handler.handler_id);
} finally {
isQueryingWorkflow.current = false;
}
};
const replayHandler = async () => {
if (isQueryingWorkflow.current) return;
isQueryingWorkflow.current = true;
try {
await workflowHandler.sendEvent(createQueryConversationHistoryEvent());
} finally {
isQueryingWorkflow.current = false;
}
};
useEffect(() => {
if (!thisHandlerId) {
createHandler();
} else {
// kick it. This is a temp workaround for a bug
store.sync().then(() => {
store.subscribe(thisHandlerId);
});
}
}, [thisHandlerId]);
useEffect(() => {
if (thisHandlerId && workflowHandler.isStreaming) {
replayHandler();
}
}, [thisHandlerId, workflowHandler.isStreaming]);
return workflowHandler;
}
+264
View File
@@ -0,0 +1,264 @@
// This is a temporary chatbot component that is used to test the chatbot functionality.
// LlamaIndex will replace it with better chatbot component.
import { WorkflowEvent } from "@llamaindex/ui";
import { useEffect, useRef, useState } from "react";
import { useChatWorkflowHandler } from "./useChatWorkflowHandler";
import { createHumanResponseEvent } from "./events";
export type Role = "user" | "assistant";
export interface Message {
role: Role;
isPartial?: boolean;
content: string;
timestamp: Date;
error?: boolean;
}
export interface ChatbotState {
submit(): Promise<void>;
retryLastMessage: () => void;
setInput: (input: string) => void;
messages: Message[];
input: string;
isLoading: boolean;
canSend: boolean;
}
export function useChatbot({
handlerId,
onHandlerCreated,
focusInput: focusInput,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
focusInput?: () => void;
}): ChatbotState {
const workflowHandler = useChatWorkflowHandler({
handlerId,
onHandlerCreated,
});
const { events } = workflowHandler;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const lastProcessedEventIndexRef = useRef<number>(0);
const [canSend, setCanSend] = useState<boolean>(false);
// Whenever handler becomes defined and changed, stop loading
useEffect(() => {
if (handlerId) {
setIsLoading(false);
setCanSend(true);
}
}, [handlerId]);
const welcomeMessage =
"Welcome! 👋 Upload a document with the control above, then ask questions here.";
// Initialize with welcome message
useEffect(() => {
if (messages.length === 0) {
const welcomeMsg: Message = {
role: "assistant",
content: welcomeMessage,
timestamp: new Date(),
};
setMessages([welcomeMsg]);
}
}, []);
// Process streamed events into messages
useEffect(() => {
if (!events || events.length === 0) return;
let startIdx = lastProcessedEventIndexRef.current;
if (startIdx < 0) startIdx = 0;
if (startIdx >= events.length) return;
const eventsToProcess = events.slice(startIdx);
const newMessages = toMessages(eventsToProcess);
if (newMessages.length > 0) {
setMessages((prev) => mergeMessages(prev, newMessages));
}
for (const ev of eventsToProcess) {
const type = ev.type;
if (!type) continue;
if (type.endsWith(".InputRequiredEvent")) {
// ready for next user input; enable send
setCanSend(true);
setIsLoading(false);
} else if (type.endsWith(".StopEvent")) {
// finished; no summary bubble needed (chat response already streamed)
}
}
lastProcessedEventIndexRef.current = events.length;
}, [events, messages]);
const retryLastMessage = () => {
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
if (lastUserMessage) {
// Remove the last assistant message if it was an error
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "assistant" && lastMessage.error) {
setMessages((prev) => prev.slice(0, -1));
}
setInput(lastUserMessage.content);
focusInput?.();
}
};
const submit = async () => {
const trimmedInput = input.trim();
if (!trimmedInput || isLoading || !canSend) return;
// Add user message
const userMessage: Message = {
role: "user",
content: trimmedInput,
timestamp: new Date(),
};
const placeHolderMessage: Message = {
role: "assistant",
content: "",
timestamp: new Date(),
isPartial: true,
};
const newMessages = [...messages, userMessage, placeHolderMessage];
setMessages(newMessages);
setInput("");
setIsLoading(true);
setCanSend(false);
try {
// Send user input as HumanResponseEvent
await workflowHandler.sendEvent(createHumanResponseEvent(trimmedInput));
} catch (err) {
console.error("Chat error:", err);
// Add error message
const errorMessage: Message = {
role: "assistant",
content: `Sorry, I encountered an error: ${err instanceof Error ? err.message : "Unknown error"}. Please try again.`,
timestamp: new Date(),
error: true,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
// Focus back on input
focusInput?.();
}
};
return {
submit,
retryLastMessage,
messages,
input,
setInput,
isLoading,
canSend,
};
}
interface AppendChatMessageData {
message: ChatMessage;
}
interface ErrorEventData {
error: string;
}
interface ChatMessage {
role: "user" | "assistant";
text: string;
sources: {
text: string;
score: number;
metadata: Record<string, any>;
}[];
timestamp: string;
}
function mergeMessages(previous: Message[], current: Message[]): Message[] {
const lastPreviousMessage = previous[previous.length - 1];
const restPrevious = previous.slice(0, -1);
const firstCurrentMessage = current[0];
const restCurrent = current.slice(1);
if (!lastPreviousMessage || !firstCurrentMessage) {
return [...previous, ...current];
}
if (lastPreviousMessage.isPartial && firstCurrentMessage.isPartial) {
const lastContent =
lastPreviousMessage.content === "Thinking..."
? ""
: lastPreviousMessage.content;
const merged = {
...lastPreviousMessage,
content: lastContent + firstCurrentMessage.content,
};
return [...restPrevious, merged, ...restCurrent];
} else if (
lastPreviousMessage.isPartial &&
firstCurrentMessage.role === lastPreviousMessage.role
) {
return [...restPrevious, firstCurrentMessage, ...restCurrent];
} else {
return [...previous, ...current];
}
}
function toMessages(events: WorkflowEvent[]): Message[] {
const messages: Message[] = [];
for (const ev of events) {
const type = ev.type;
const data = ev.data as any;
const lastMessage = messages[messages.length - 1];
if (type.endsWith(".ChatDeltaEvent")) {
const delta: string = data?.delta ?? "";
if (!delta) continue;
if (!lastMessage || !lastMessage.isPartial) {
messages.push({
role: "assistant",
content: delta,
isPartial: true,
timestamp: new Date(),
});
} else {
lastMessage.content += delta;
}
} else if (type.endsWith(".AppendChatMessage")) {
if (
lastMessage &&
lastMessage.isPartial &&
lastMessage.role === "assistant"
) {
messages.pop();
}
const content = ev.data as unknown as AppendChatMessageData;
messages.push({
role: content.message.role,
content: content.message.text,
timestamp: new Date(content.message.timestamp),
isPartial: false,
});
} else if (type.endsWith(".ErrorEvent")) {
if (
lastMessage &&
lastMessage.isPartial &&
lastMessage.role === "assistant"
) {
messages.pop();
}
const content = ev.data as unknown as ErrorEventData;
messages.push({
role: "assistant",
content: content.error,
timestamp: new Date(),
isPartial: false,
error: true,
});
}
}
return messages;
}
+52 -28
View File
@@ -1,37 +1,61 @@
import ChatBot from "../components/ChatBot";
import { WorkflowTrigger } from "@llamaindex/ui";
import { useWorkflowHandlerList, WorkflowTrigger } from "@llamaindex/ui";
import { APP_TITLE, INDEX_NAME } from "../libs/config";
import { useChatHistory } from "@/libs/useChatHistory";
import Sidebar from "@/components/Sidebar";
import { Loader } from "lucide-react";
export default function Home() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{APP_TITLE}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Upload documents and ask questions about them
</p>
</header>
const chatHistory = useChatHistory();
const handlers = useWorkflowHandlerList("upload");
const activeHandlers = handlers.handlers.filter(
(h) => h.status === "running" && h.workflowName === "upload",
);
const anyActiveHandlers = activeHandlers.length > 0;
<div>
<div className="flex mb-4">
<WorkflowTrigger
workflowName="upload"
customWorkflowInput={(files, fieldValues) => {
return {
file_id: files[0].fileId,
index_name: INDEX_NAME,
};
}}
/>
</div>
<div>
<div className="h-[700px]">
<ChatBot />
return (
<div className="min-h-screen bg-background">
<div className="flex h-screen">
<Sidebar chatHistory={chatHistory} />
<div className="flex-1 flex flex-col">
{/* Simplified header with upload functionality */}
<header className="flex items-center justify-between p-4 border-b bg-card">
<div>
<h1 className="text-xl font-semibold text-foreground">
{APP_TITLE}
</h1>
<p className="text-sm text-muted-foreground">
Upload documents and ask questions about them
</p>
</div>
<div className="flex items-center gap-3">
<WorkflowTrigger
workflowName="upload"
customWorkflowInput={(files, fieldValues) => {
return {
file_id: files[0].fileId,
index_name: INDEX_NAME,
};
}}
/>
{anyActiveHandlers && (
<Loader className="w-4 h-4 animate-spin text-muted-foreground" />
)}
</div>
</header>
{/* Main chat area */}
<div className="flex-1 overflow-hidden">
{!chatHistory.loading && (
<ChatBot
key={`${chatHistory.chatCounter}-${chatHistory.selectedChatId || "new"}`}
handlerId={chatHistory.selectedChatId ?? undefined}
onHandlerCreated={(handler) => {
chatHistory.addChat(handler);
chatHistory.setSelectedChatId(handler);
}}
/>
)}
</div>
</div>
</div>
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Start UI</title>
<script>
// Prevent dark mode flash - respect system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -17,6 +17,7 @@
"@llamaindex/ui": "^2.1.1",
"@llamaindex/workflows-client": "^1.2.0",
"@radix-ui/themes": "^3.2.1",
"idb": "^8.0.3",
"llama-cloud-services": "^0.3.6",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
+21
View File
@@ -2,8 +2,29 @@ import { ApiProvider } from "@llamaindex/ui";
import Home from "./pages/Home";
import { Theme } from "@radix-ui/themes";
import { clients } from "@/libs/clients";
import { useEffect } from "react";
export default function App() {
// Apply dark mode based on system preference
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateDarkMode = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
// Set initial state
updateDarkMode(mediaQuery);
// Listen for changes
mediaQuery.addEventListener("change", updateDarkMode);
return () => mediaQuery.removeEventListener("change", updateDarkMode);
}, []);
return (
<Theme>
<ApiProvider clients={clients}>
+174 -431
View File
@@ -1,167 +1,30 @@
// This is a temporary chatbot component that is used to test the chatbot functionality.
// LlamaIndex will replace it with better chatbot component.
import { useState, useRef, useEffect, FormEvent, KeyboardEvent } from "react";
import {
Send,
Loader2,
Bot,
User,
MessageSquare,
Trash2,
RefreshCw,
} from "lucide-react";
import {
Button,
Input,
ScrollArea,
Card,
CardContent,
cn,
useWorkflowRun,
useWorkflowHandler,
} from "@llamaindex/ui";
import { AGENT_NAME } from "../libs/config";
import { toHumanResponseRawEvent } from "@/libs/utils";
import { useChatbot } from "@/libs/useChatbot";
import { Button, cn, ScrollArea, Textarea } from "@llamaindex/ui";
import { Bot, Loader2, RefreshCw, Send, User } from "lucide-react";
import { FormEvent, KeyboardEvent, useEffect, useRef } from "react";
type Role = "user" | "assistant";
interface Message {
id: string;
role: Role;
content: string;
timestamp: Date;
error?: boolean;
}
export default function ChatBot() {
const { runWorkflow } = useWorkflowRun();
export default function ChatBot({
handlerId,
onHandlerCreated,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [handlerId, setHandlerId] = useState<string | null>(null);
const lastProcessedEventIndexRef = useRef<number>(0);
const [canSend, setCanSend] = useState<boolean>(false);
const streamingMessageIndexRef = useRef<number | null>(null);
// Deployment + auth setup
const deployment = AGENT_NAME || "document-qa";
const platformToken = (import.meta as any).env?.VITE_LLAMA_CLOUD_API_KEY as
| string
| undefined;
const projectId = (import.meta as any).env?.VITE_LLAMA_DEPLOY_PROJECT_ID as
| string
| undefined;
const defaultIndexName =
(import.meta as any).env?.VITE_DEFAULT_INDEX_NAME || "document_qa_index";
const sessionIdRef = useRef<string>(
`chat-${Math.random().toString(36).slice(2)}-${Date.now()}`,
);
const chatbot = useChatbot({
handlerId,
onHandlerCreated,
focusInput: () => {
inputRef.current?.focus();
},
});
// UI text defaults
const title = "AI Document Assistant";
const placeholder = "Ask me anything about your documents...";
const welcomeMessage =
"Welcome! 👋 Upload a document with the control above, then ask questions here.";
// Helper functions for message management
const appendMessage = (role: Role, msg: string): void => {
setMessages((prev) => {
const id = `${role}-stream-${Date.now()}`;
const idx = prev.length;
streamingMessageIndexRef.current = idx;
return [
...prev,
{
id,
role,
content: msg,
timestamp: new Date(),
},
];
});
};
const updateMessage = (index: number, message: string) => {
setMessages((prev) => {
if (index < 0 || index >= prev.length) return prev;
const copy = [...prev];
const existing = copy[index];
copy[index] = { ...existing, content: message };
return copy;
});
};
// Initialize with welcome message
useEffect(() => {
if (messages.length === 0) {
const welcomeMsg: Message = {
id: "welcome",
role: "assistant",
content: welcomeMessage,
timestamp: new Date(),
};
setMessages([welcomeMsg]);
}
}, []);
// Create chat task on init
useEffect(() => {
(async () => {
if (!handlerId) {
const handler = await runWorkflow("chat", {
index_name: defaultIndexName,
session_id: sessionIdRef.current,
});
setHandlerId(handler.handler_id);
}
})();
}, []);
// Subscribe to task/events using hook (auto stream when handler exists)
const { events } = useWorkflowHandler(handlerId ?? "", Boolean(handlerId));
// Process streamed events into messages
useEffect(() => {
if (!events || events.length === 0) return;
let startIdx = lastProcessedEventIndexRef.current;
if (startIdx < 0) startIdx = 0;
if (startIdx >= events.length) return;
for (let i = startIdx; i < events.length; i++) {
const ev: any = events[i];
const type = ev?.type as string | undefined;
const rawData = ev?.data as any;
if (!type) continue;
const data = (rawData && (rawData._data ?? rawData)) as any;
if (type.includes("ChatDeltaEvent")) {
const delta: string = data?.delta ?? "";
if (!delta) continue;
if (streamingMessageIndexRef.current === null) {
appendMessage("assistant", delta);
} else {
const idx = streamingMessageIndexRef.current;
const current = messages[idx!]?.content ?? "";
if (current === "Thinking...") {
updateMessage(idx!, delta);
} else {
updateMessage(idx!, current + delta);
}
}
} else if (type.includes("ChatResponseEvent")) {
// finalize current stream
streamingMessageIndexRef.current = null;
} else if (type.includes("InputRequiredEvent")) {
// ready for next user input; enable send
setCanSend(true);
setIsLoading(false);
inputRef.current?.focus();
} else if (type.includes("StopEvent")) {
// finished; no summary bubble needed (chat response already streamed)
}
}
lastProcessedEventIndexRef.current = events.length;
}, [events, messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -169,324 +32,204 @@ export default function ChatBot() {
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [chatbot.messages]);
// No manual SSE cleanup needed
const getCommonHeaders = () => ({
...(platformToken ? { authorization: `Bearer ${platformToken}` } : {}),
...(projectId ? { "Project-Id": projectId } : {}),
});
const startChatIfNeeded = async (): Promise<string> => {
if (handlerId) return handlerId;
const handler = await runWorkflow("chat", {
index_name: defaultIndexName,
session_id: sessionIdRef.current,
});
setHandlerId(handler.handler_id);
return handler.handler_id;
};
// Removed manual SSE ensureEventStream; hook handles streaming
// Reset textarea height when input is cleared
useEffect(() => {
if (!chatbot.input && inputRef.current) {
inputRef.current.style.height = "48px"; // Reset to initial height
}
}, [chatbot.input]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading || !canSend) return;
// Add user message
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: trimmedInput,
timestamp: new Date(),
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsLoading(true);
setCanSend(false);
// Immediately create an assistant placeholder to avoid visual gap before deltas
if (streamingMessageIndexRef.current === null) {
appendMessage("assistant", "Thinking...");
}
try {
// Ensure chat handler exists (created on init)
const hid = await startChatIfNeeded();
// Send user input as HumanResponseEvent
const postRes = await fetch(`/deployments/${deployment}/events/${hid}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getCommonHeaders(),
},
body: JSON.stringify({
event: JSON.stringify(toHumanResponseRawEvent(trimmedInput)),
}),
});
if (!postRes.ok) {
throw new Error(
`Failed to send message: ${postRes.status} ${postRes.statusText}`,
);
}
// The assistant reply will be streamed by useWorkflowTask and appended incrementally
} catch (err) {
console.error("Chat error:", err);
// Add error message
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, I encountered an error: ${err instanceof Error ? err.message : "Unknown error"}. Please try again.`,
timestamp: new Date(),
error: true,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
// Focus back on input
inputRef.current?.focus();
}
await chatbot.submit();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
// Allow Shift+Enter to create new line (default behavior)
};
const clearChat = () => {
setMessages([
{
id: "welcome",
role: "assistant" as const,
content: welcomeMessage,
timestamp: new Date(),
},
]);
setInput("");
inputRef.current?.focus();
const adjustTextareaHeight = (textarea: HTMLTextAreaElement) => {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 128) + "px"; // 128px = max-h-32
};
const retryLastMessage = () => {
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
if (lastUserMessage) {
// Remove the last assistant message if it was an error
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "assistant" && lastMessage.error) {
setMessages((prev) => prev.slice(0, -1));
}
setInput(lastUserMessage.content);
inputRef.current?.focus();
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
chatbot.setInput(e.target.value);
adjustTextareaHeight(e.target);
};
return (
<div
className={cn(
"flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg shadow-lg",
)}
>
{/* Header */}
<div className="px-4 py-3 border-b dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h3 className="font-semibold text-gray-900 dark:text-white">
{title}
</h3>
{isLoading && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Thinking...
</span>
)}
</div>
<div className="flex items-center gap-2">
{messages.some((m) => m.error) && (
<button
onClick={retryLastMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Retry last message"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{messages.length > 0 && (
<button
onClick={clearChat}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Clear chat"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<div className="flex flex-col h-full bg-background">
{/* Simplified header - only show retry button when needed */}
{chatbot.messages.some((m) => m.error) && (
<div className="flex justify-center">
<div className="w-full max-w-4xl px-4 py-2 border-b bg-muted/30">
<button
onClick={chatbot.retryLastMessage}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
title="Retry last message"
>
<RefreshCw className="w-4 h-4" />
Retry last message
</button>
</div>
</div>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[300px]">
<div className="text-center">
<Bot className="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 dark:text-gray-400 mb-2">
No messages yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
Start a conversation!
</p>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6">
{chatbot.messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[400px]">
<div className="text-center">
<Bot className="w-16 h-16 text-muted-foreground/50 mx-auto mb-4" />
<p className="text-lg text-foreground mb-2">
Welcome! 👋 Upload a document with the control above, then ask
questions here.
</p>
<p className="text-sm text-muted-foreground">
Start by uploading a document to begin your conversation
</p>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role !== "user" && (
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
message.error
? "bg-red-100 dark:bg-red-900"
: "bg-blue-100 dark:bg-blue-900",
)}
>
<Bot
className={cn(
"w-5 h-5",
message.error
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
/>
</div>
)}
) : (
<div className="space-y-6">
{chatbot.messages.map((message, i) => (
<div
key={i}
className={cn(
"max-w-[70%]",
message.role === "user" ? "order-1" : "order-2",
"flex gap-4",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
<Card
{message.role !== "user" && (
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-1",
message.error
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
<Bot className="w-5 h-5" />
</div>
)}
<div
className={cn(
"py-0",
message.role === "user"
? "bg-blue-600 text-white border-blue-600"
: message.error
? "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
: "bg-gray-50 dark:bg-gray-700",
"max-w-[75%]",
message.role === "user" ? "order-1" : "order-2",
)}
>
<CardContent className="p-3">
<div
className={cn(
"rounded-2xl px-4 py-3",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.error
? "bg-destructive/5 border border-destructive/20"
: "bg-muted",
)}
>
{message.isPartial && !message.content ? (
<div className="m-2">
<LoadingDots />
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
)}
<p
className={cn(
"whitespace-pre-wrap text-sm",
message.error && "text-red-700 dark:text-red-400",
)}
>
{message.content}
</p>
<p
className={cn(
"text-xs mt-1 opacity-70",
"text-xs mt-2 opacity-60",
message.role === "user"
? "text-blue-100"
? "text-primary-foreground"
: message.error
? "text-red-500 dark:text-red-400"
: "text-gray-500 dark:text-gray-400",
? "text-destructive"
: "text-muted-foreground",
)}
>
{message.timestamp.toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
{message.role === "user" && (
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 order-2">
<User className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<Bot className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<Card className="bg-gray-50 dark:bg-gray-700 py-0">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
></span>
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
></span>
<span
className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
></span>
</div>
</div>
</CardContent>
</Card>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{message.role === "user" && (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 order-2 mt-1">
<User className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
</ScrollArea>
{/* Input */}
<div className="border-t dark:border-gray-700 p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
className="flex-1"
autoFocus
/>
<Button
type="submit"
disabled={!canSend || isLoading || !input.trim()}
size="icon"
title="Send message"
>
{!canSend || isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
</form>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Press Enter to send Shift+Enter for new line
</p>
<div className="border-t bg-background">
<div className="max-w-4xl mx-auto p-6">
<form onSubmit={handleSubmit} className="flex gap-3">
<Textarea
ref={inputRef}
value={chatbot.input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={chatbot.isLoading}
className="flex-1 min-h-12 max-h-32 rounded-xl border-2 focus:border-primary resize-none overflow-hidden"
autoFocus
style={{ height: "48px" }} // Initial height (min-h-12)
/>
<Button
type="submit"
disabled={
!chatbot.canSend || chatbot.isLoading || !chatbot.input.trim()
}
size="icon"
title="Send message"
className="h-12 w-12 rounded-xl"
>
{!chatbot.canSend || chatbot.isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</Button>
</form>
<p className="text-xs text-muted-foreground mt-3 text-center">
Press Enter to send Shift+Enter for new line
</p>
</div>
</div>
</div>
);
}
const LoadingDots = () => {
return (
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
></span>
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
></span>
<span
className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
></span>
</div>
</div>
);
};
+181
View File
@@ -0,0 +1,181 @@
import { Plus, X, ChevronLeft, ChevronRight } from "lucide-react";
import { Button, ScrollArea, cn } from "@llamaindex/ui";
import { ChatHistory, UseChatHistory } from "../libs/useChatHistory";
import { useState } from "react";
interface SidebarProps {
className?: string;
chatHistory: UseChatHistory;
}
export default function Sidebar({ className, chatHistory }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const {
loading,
chats,
selectedChatId,
setSelectedChatId,
deleteChat,
createNewChat,
} = chatHistory;
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const timeString = date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
if (isToday) {
return timeString;
} else {
const dateString = date.toLocaleDateString();
return `${dateString} ${timeString}`;
}
};
const handleChatSelect = (chat: ChatHistory): void => {
setSelectedChatId(chat.handlerId);
};
const handleDeleteChat = (e: React.MouseEvent, handlerId: string): void => {
e.stopPropagation();
deleteChat(handlerId);
};
return (
<div
className={cn(
"flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300",
isCollapsed ? "w-16" : "w-[280px]",
className,
)}
>
{/* Header */}
<div className="px-4 py-4 border-b border-sidebar-border">
<div className="flex items-center justify-between">
{!isCollapsed && (
<h3 className="text-sm font-medium text-sidebar-foreground">
Chats
</h3>
)}
<div className="flex items-center gap-1">
{!isCollapsed && (
<Button
size="sm"
variant="ghost"
onClick={createNewChat}
className="h-8 w-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title="New Chat"
>
<Plus className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => setIsCollapsed(!isCollapsed)}
className="h-8 w-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
{/* Chat List */}
<ScrollArea className="flex-1">
{isCollapsed ? (
// Collapsed state - show dots for each chat
<div className="p-2 space-y-2">
{!loading && chats.length > 0 && (
<>
{chats.map((chat) => (
<div
key={chat.handlerId}
className={cn(
"w-8 h-8 rounded-lg cursor-pointer transition-colors flex items-center justify-center",
selectedChatId === chat.handlerId
? "bg-sidebar-primary"
: "hover:bg-sidebar-accent",
)}
onClick={() => handleChatSelect(chat)}
title={formatTimestamp(chat.timestamp)}
>
<div className="w-2 h-2 rounded-full bg-current opacity-70" />
</div>
))}
<Button
size="sm"
variant="ghost"
onClick={createNewChat}
className="w-8 h-8 p-0 hover:bg-sidebar-accent text-sidebar-foreground hover:text-sidebar-accent-foreground"
title="New Chat"
>
<Plus className="w-4 h-4" />
</Button>
</>
)}
</div>
) : (
// Expanded state
<>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">Loading...</div>
</div>
) : chats.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
No chats yet
</div>
</div>
) : (
<div className="p-2 space-y-1">
{chats.map((chat) => (
<div
key={chat.handlerId}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer transition-colors",
selectedChatId === chat.handlerId
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
onClick={() => handleChatSelect(chat)}
>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">
{formatTimestamp(chat.timestamp)}
</div>
</div>
<button
onClick={(e) => handleDeleteChat(e, chat.handlerId)}
className={cn(
"ml-2 p-1 rounded transition-colors",
selectedChatId === chat.handlerId
? "text-sidebar-primary-foreground/70 hover:text-sidebar-primary-foreground hover:bg-sidebar-primary-foreground/10"
: "text-muted-foreground hover:text-destructive",
)}
aria-label="Delete chat"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</ScrollArea>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { WorkflowEvent } from "@llamaindex/ui";
export function createQueryConversationHistoryEvent(): WorkflowEvent {
return {
data: {},
type: "{{ project_name_snake }}.qa_workflows.QueryConversationHistoryEvent",
};
}
export function createHumanResponseEvent(response: string): WorkflowEvent {
return {
data: { _data: { response } },
type: "{{ project_name_snake }}.qa_workflows.HumanResponseEvent",
};
}
+185
View File
@@ -0,0 +1,185 @@
import { IDBPDatabase, openDB } from "idb";
import { useEffect, useState } from "react";
export interface ChatHistory {
handlerId: string;
timestamp: string;
}
export interface UseChatHistory {
loading: boolean;
addChat(handlerId: string): void;
deleteChat(handlerId: string): void;
chats: ChatHistory[];
selectedChatId: string | null;
setSelectedChatId(handlerId: string): void;
createNewChat(): void;
// forces a new chat
chatCounter: number;
}
const DB_NAME = "chat-history";
const DB_VERSION = 1;
const STORE_NAME = "chats";
/**
* Hook that tracks workflow handler ids, to use as markers of a chat conversation that can be reloaded.
* Stores chats in IndexedDB
* @returns
*/
export function useChatHistory(): UseChatHistory {
const [loading, setLoading] = useState(true);
const [chatHistory, setChatHistory] = useState<ChatHistory[]>([]);
const [selectedChatHandlerId, setSelectedChatHandlerId] = useState<
string | null
>(null);
const [db, setDb] = useState<IDBPDatabase<unknown> | null>(null);
const [chatCounter, setChatCounter] = useState(0);
// Initialize database
useEffect(() => {
let thisDb: IDBPDatabase<unknown> | null = null;
const initDb = async () => {
try {
thisDb = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "handlerId",
});
store.createIndex("timestamp", "timestamp");
}
},
});
setDb(thisDb);
} catch (error) {
console.error("Failed to initialize database:", error);
setLoading(false);
}
};
initDb();
return () => {
thisDb?.close();
};
}, []);
// Load chat history when database is ready
useEffect(() => {
if (!db) return;
const loadChats = async () => {
try {
setLoading(true);
const chats = await getChatsFromDb();
setChatHistory(chats);
// Initialize selectedChat to the latest chat (first in sorted array)
if (chats.length > 0 && !selectedChatHandlerId) {
setSelectedChatHandlerId(chats[0].handlerId);
}
} catch (error) {
console.error("Failed to load chat history:", error);
} finally {
setLoading(false);
}
};
loadChats();
}, [db]);
const getChatsFromDb = async (): Promise<ChatHistory[]> => {
if (!db) return [];
try {
const transaction = db.transaction(STORE_NAME, "readonly");
const store = transaction.objectStore(STORE_NAME);
const index = store.index("timestamp");
const chats = await index.getAll();
// Sort by timestamp descending (most recent first)
return chats.sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
} catch (error) {
console.error("Failed to get chats from database:", error);
return [];
}
};
const addChat = async (handlerId: string): Promise<void> => {
if (!db) return;
try {
const chat: ChatHistory = {
handlerId,
timestamp: new Date().toISOString(),
};
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
await store.put(chat);
// Update local state
setChatHistory((prev) => [
chat,
...prev.filter((c) => c.handlerId !== handlerId),
]);
// Set as selected chat if it's the first chat or if no chat is currently selected
if (!selectedChatHandlerId) {
setSelectedChatHandlerId(chat.handlerId);
}
} catch (error) {
console.error("Failed to add chat to database:", error);
}
};
const deleteChat = async (handlerId: string): Promise<void> => {
if (!db) return;
try {
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
await store.delete(handlerId);
// Update local state
setChatHistory((prev) => prev.filter((c) => c.handlerId !== handlerId));
// If the deleted chat was selected, select the next available chat or clear selection
if (selectedChatHandlerId === handlerId) {
const remainingChats = chatHistory.filter(
(c) => c.handlerId !== handlerId,
);
if (remainingChats.length > 0) {
setSelectedChatHandlerId(remainingChats[0].handlerId);
setChatCounter((prev) => prev + 1);
} else {
setSelectedChatHandlerId(null);
setChatCounter((prev) => prev + 1);
}
}
} catch (error) {
console.error("Failed to delete chat from database:", error);
}
};
const createNewChat = (): void => {
setSelectedChatHandlerId(null);
setChatCounter((prev) => prev + 1);
};
return {
loading,
addChat,
chats: chatHistory,
selectedChatId: selectedChatHandlerId,
setSelectedChatId: setSelectedChatHandlerId,
deleteChat,
createNewChat,
chatCounter,
};
}
+69
View File
@@ -0,0 +1,69 @@
import {
useWorkflowHandler,
useWorkflowRun,
useHandlerStore,
} from "@llamaindex/ui";
import { useEffect, useRef, useState } from "react";
import { INDEX_NAME } from "./config";
import { createQueryConversationHistoryEvent } from "./events";
/**
* Creates a new chat conversation if no handlerId is provided
*/
export function useChatWorkflowHandler({
handlerId,
onHandlerCreated,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
}): ReturnType<typeof useWorkflowHandler> {
const create = useWorkflowRun();
const isQueryingWorkflow = useRef(false);
const [thisHandlerId, setThisHandlerId] = useState<string | undefined>(
handlerId,
);
const workflowHandler = useWorkflowHandler(thisHandlerId ?? "", true);
const store = useHandlerStore();
const createHandler = async () => {
if (isQueryingWorkflow.current) return;
isQueryingWorkflow.current = true;
try {
const handler = await create.runWorkflow("chat", {
index_name: INDEX_NAME,
});
setThisHandlerId(handler.handler_id);
onHandlerCreated?.(handler.handler_id);
} finally {
isQueryingWorkflow.current = false;
}
};
const replayHandler = async () => {
if (isQueryingWorkflow.current) return;
isQueryingWorkflow.current = true;
try {
await workflowHandler.sendEvent(createQueryConversationHistoryEvent());
} finally {
isQueryingWorkflow.current = false;
}
};
useEffect(() => {
if (!thisHandlerId) {
createHandler();
} else {
// kick it. This is a temp workaround for a bug
store.sync().then(() => {
store.subscribe(thisHandlerId);
});
}
}, [thisHandlerId]);
useEffect(() => {
if (thisHandlerId && workflowHandler.isStreaming) {
replayHandler();
}
}, [thisHandlerId, workflowHandler.isStreaming]);
return workflowHandler;
}
+264
View File
@@ -0,0 +1,264 @@
// This is a temporary chatbot component that is used to test the chatbot functionality.
// LlamaIndex will replace it with better chatbot component.
import { WorkflowEvent } from "@llamaindex/ui";
import { useEffect, useRef, useState } from "react";
import { useChatWorkflowHandler } from "./useChatWorkflowHandler";
import { createHumanResponseEvent } from "./events";
export type Role = "user" | "assistant";
export interface Message {
role: Role;
isPartial?: boolean;
content: string;
timestamp: Date;
error?: boolean;
}
export interface ChatbotState {
submit(): Promise<void>;
retryLastMessage: () => void;
setInput: (input: string) => void;
messages: Message[];
input: string;
isLoading: boolean;
canSend: boolean;
}
export function useChatbot({
handlerId,
onHandlerCreated,
focusInput: focusInput,
}: {
handlerId?: string;
onHandlerCreated?: (handlerId: string) => void;
focusInput?: () => void;
}): ChatbotState {
const workflowHandler = useChatWorkflowHandler({
handlerId,
onHandlerCreated,
});
const { events } = workflowHandler;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const lastProcessedEventIndexRef = useRef<number>(0);
const [canSend, setCanSend] = useState<boolean>(false);
// Whenever handler becomes defined and changed, stop loading
useEffect(() => {
if (handlerId) {
setIsLoading(false);
setCanSend(true);
}
}, [handlerId]);
const welcomeMessage =
"Welcome! 👋 Upload a document with the control above, then ask questions here.";
// Initialize with welcome message
useEffect(() => {
if (messages.length === 0) {
const welcomeMsg: Message = {
role: "assistant",
content: welcomeMessage,
timestamp: new Date(),
};
setMessages([welcomeMsg]);
}
}, []);
// Process streamed events into messages
useEffect(() => {
if (!events || events.length === 0) return;
let startIdx = lastProcessedEventIndexRef.current;
if (startIdx < 0) startIdx = 0;
if (startIdx >= events.length) return;
const eventsToProcess = events.slice(startIdx);
const newMessages = toMessages(eventsToProcess);
if (newMessages.length > 0) {
setMessages((prev) => mergeMessages(prev, newMessages));
}
for (const ev of eventsToProcess) {
const type = ev.type;
if (!type) continue;
if (type.endsWith(".InputRequiredEvent")) {
// ready for next user input; enable send
setCanSend(true);
setIsLoading(false);
} else if (type.endsWith(".StopEvent")) {
// finished; no summary bubble needed (chat response already streamed)
}
}
lastProcessedEventIndexRef.current = events.length;
}, [events, messages]);
const retryLastMessage = () => {
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
if (lastUserMessage) {
// Remove the last assistant message if it was an error
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "assistant" && lastMessage.error) {
setMessages((prev) => prev.slice(0, -1));
}
setInput(lastUserMessage.content);
focusInput?.();
}
};
const submit = async () => {
const trimmedInput = input.trim();
if (!trimmedInput || isLoading || !canSend) return;
// Add user message
const userMessage: Message = {
role: "user",
content: trimmedInput,
timestamp: new Date(),
};
const placeHolderMessage: Message = {
role: "assistant",
content: "",
timestamp: new Date(),
isPartial: true,
};
const newMessages = [...messages, userMessage, placeHolderMessage];
setMessages(newMessages);
setInput("");
setIsLoading(true);
setCanSend(false);
try {
// Send user input as HumanResponseEvent
await workflowHandler.sendEvent(createHumanResponseEvent(trimmedInput));
} catch (err) {
console.error("Chat error:", err);
// Add error message
const errorMessage: Message = {
role: "assistant",
content: `Sorry, I encountered an error: ${err instanceof Error ? err.message : "Unknown error"}. Please try again.`,
timestamp: new Date(),
error: true,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
// Focus back on input
focusInput?.();
}
};
return {
submit,
retryLastMessage,
messages,
input,
setInput,
isLoading,
canSend,
};
}
interface AppendChatMessageData {
message: ChatMessage;
}
interface ErrorEventData {
error: string;
}
interface ChatMessage {
role: "user" | "assistant";
text: string;
sources: {
text: string;
score: number;
metadata: Record<string, any>;
}[];
timestamp: string;
}
function mergeMessages(previous: Message[], current: Message[]): Message[] {
const lastPreviousMessage = previous[previous.length - 1];
const restPrevious = previous.slice(0, -1);
const firstCurrentMessage = current[0];
const restCurrent = current.slice(1);
if (!lastPreviousMessage || !firstCurrentMessage) {
return [...previous, ...current];
}
if (lastPreviousMessage.isPartial && firstCurrentMessage.isPartial) {
const lastContent =
lastPreviousMessage.content === "Thinking..."
? ""
: lastPreviousMessage.content;
const merged = {
...lastPreviousMessage,
content: lastContent + firstCurrentMessage.content,
};
return [...restPrevious, merged, ...restCurrent];
} else if (
lastPreviousMessage.isPartial &&
firstCurrentMessage.role === lastPreviousMessage.role
) {
return [...restPrevious, firstCurrentMessage, ...restCurrent];
} else {
return [...previous, ...current];
}
}
function toMessages(events: WorkflowEvent[]): Message[] {
const messages: Message[] = [];
for (const ev of events) {
const type = ev.type;
const data = ev.data as any;
const lastMessage = messages[messages.length - 1];
if (type.endsWith(".ChatDeltaEvent")) {
const delta: string = data?.delta ?? "";
if (!delta) continue;
if (!lastMessage || !lastMessage.isPartial) {
messages.push({
role: "assistant",
content: delta,
isPartial: true,
timestamp: new Date(),
});
} else {
lastMessage.content += delta;
}
} else if (type.endsWith(".AppendChatMessage")) {
if (
lastMessage &&
lastMessage.isPartial &&
lastMessage.role === "assistant"
) {
messages.pop();
}
const content = ev.data as unknown as AppendChatMessageData;
messages.push({
role: content.message.role,
content: content.message.text,
timestamp: new Date(content.message.timestamp),
isPartial: false,
});
} else if (type.endsWith(".ErrorEvent")) {
if (
lastMessage &&
lastMessage.isPartial &&
lastMessage.role === "assistant"
) {
messages.pop();
}
const content = ev.data as unknown as ErrorEventData;
messages.push({
role: "assistant",
content: content.error,
timestamp: new Date(),
isPartial: false,
error: true,
});
}
}
return messages;
}
+52 -28
View File
@@ -1,37 +1,61 @@
import ChatBot from "../components/ChatBot";
import { WorkflowTrigger } from "@llamaindex/ui";
import { useWorkflowHandlerList, WorkflowTrigger } from "@llamaindex/ui";
import { APP_TITLE, INDEX_NAME } from "../libs/config";
import { useChatHistory } from "@/libs/useChatHistory";
import Sidebar from "@/components/Sidebar";
import { Loader } from "lucide-react";
export default function Home() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{APP_TITLE}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Upload documents and ask questions about them
</p>
</header>
const chatHistory = useChatHistory();
const handlers = useWorkflowHandlerList("upload");
const activeHandlers = handlers.handlers.filter(
(h) => h.status === "running" && h.workflowName === "upload",
);
const anyActiveHandlers = activeHandlers.length > 0;
<div>
<div className="flex mb-4">
<WorkflowTrigger
workflowName="upload"
customWorkflowInput={(files, fieldValues) => {
return {
file_id: files[0].fileId,
index_name: INDEX_NAME,
};
}}
/>
</div>
<div>
<div className="h-[700px]">
<ChatBot />
return (
<div className="min-h-screen bg-background">
<div className="flex h-screen">
<Sidebar chatHistory={chatHistory} />
<div className="flex-1 flex flex-col">
{/* Simplified header with upload functionality */}
<header className="flex items-center justify-between p-4 border-b bg-card">
<div>
<h1 className="text-xl font-semibold text-foreground">
{APP_TITLE}
</h1>
<p className="text-sm text-muted-foreground">
Upload documents and ask questions about them
</p>
</div>
<div className="flex items-center gap-3">
<WorkflowTrigger
workflowName="upload"
customWorkflowInput={(files, fieldValues) => {
return {
file_id: files[0].fileId,
index_name: INDEX_NAME,
};
}}
/>
{anyActiveHandlers && (
<Loader className="w-4 h-4 animate-spin text-muted-foreground" />
)}
</div>
</header>
{/* Main chat area */}
<div className="flex-1 overflow-hidden">
{!chatHistory.loading && (
<ChatBot
key={`${chatHistory.chatCounter}-${chatHistory.selectedChatId || "new"}`}
handlerId={chatHistory.selectedChatId ?? undefined}
onHandlerCreated={(handler) => {
chatHistory.addChat(handler);
chatHistory.setSelectedChatId(handler);
}}
/>
)}
</div>
</div>
</div>