mirror of
https://github.com/run-llama/template-workflow-document-qa.git
synced 2026-06-30 21:47:58 -04:00
Add workflow/chat history (#6)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user