Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot] 63558c11fa Release 0.3.10 (#407)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-01 16:07:15 +07:00
Thuc Pham 9172fed2e8 feat: bump LITS 0.8.2 (#406)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-11-01 15:06:31 +07:00
Thuc Pham 78ccde78fc feat: integrate llamaindex chat-ui (#399)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-11-01 12:19:29 +07:00
github-actions[bot] 02510703d8 Release 0.3.9 (#405)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-31 16:05:33 +07:00
Huu Le ed59927bd0 feat: Add form filling use case for Python (#403)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-10-31 16:01:53 +07:00
Thuc Pham 9f866aa981 fix: use uploaded filename to build file url (#404) 2024-10-30 14:47:11 +07:00
github-actions[bot] b8f78612b8 Release 0.3.8 (#396)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-25 16:47:26 +07:00
Huu Le 4a8346900d feat: Add multi-agent financial report use case for TS (#394) 2024-10-25 16:44:56 +07:00
github-actions[bot] 42e63842d0 Release 0.3.7 (#395)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-25 14:34:55 +07:00
Huu Le fa803787e3 relative url (#393) 2024-10-25 14:13:34 +07:00
66 changed files with 1526 additions and 1987 deletions
+25
View File
@@ -1,5 +1,30 @@
# create-llama
## 0.3.10
### Patch Changes
- 9172fed: feat: bump LITS 0.8.2
- 78ccde7: feat: use llamaindex chat-ui for nextjs frontend
## 0.3.9
### Patch Changes
- ed59927: Add form filling use case (Python)
## 0.3.8
### Patch Changes
- 4a83469: Add multi-agent financial report for Typescript (and update LITS to 0.7.10)
## 0.3.7
### Patch Changes
- fa80378: DocumentInfo working with relative URLs
## 0.3.6
### Patch Changes
+16
View File
@@ -267,6 +267,22 @@ For better results, you can specify the region parameter to get results from a s
},
],
},
{
display: "Form Filling",
name: "form_filling",
supportedFrameworks: ["fastapi"],
type: ToolType.LOCAL,
dependencies: [
{
name: "pandas",
version: "^2.2.3",
},
{
name: "tabulate",
version: "^0.9.0",
},
],
},
];
export const getTool = (toolName: string): Tool | undefined => {
+1 -1
View File
@@ -48,7 +48,7 @@ export type TemplateDataSource = {
};
export type TemplateDataSourceType = "file" | "web" | "db";
export type TemplateObservability = "none" | "traceloop" | "llamatrace";
export type TemplateAgents = "financial_report" | "blog";
export type TemplateAgents = "financial_report" | "blog" | "form_filling";
// Config for both file and folder
export type FileSourceConfig =
| {
+27 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { bold, cyan, yellow } from "picocolors";
import { bold, cyan, red, yellow } from "picocolors";
import { assetRelocator, copy } from "../helpers/copy";
import { callPackageManager } from "../helpers/install";
import { templatesDir } from "./dir";
@@ -26,6 +26,7 @@ export const installTSTemplate = async ({
tools,
dataSources,
useLlamaParse,
agents,
}: InstallTemplateArgs & { backend: boolean }) => {
console.log(bold(`Using ${packageManager}.`));
@@ -132,6 +133,31 @@ export const installTSTemplate = async ({
cwd: path.join(multiagentPath, "workflow"),
});
// Copy agents use case code for multiagent template
if (agents) {
console.log("\nCopying agent:", agents, "\n");
const agentsCodePath = path.join(
compPath,
"agents",
"typescript",
agents,
);
await copy("**", path.join(root, relativeEngineDestPath, "workflow"), {
parents: true,
cwd: agentsCodePath,
rename: assetRelocator,
});
} else {
console.log(
red(
"There is no agent selected for multi-agent template. Please pick an agent to use via --agents flag.",
),
);
process.exit(1);
}
if (framework === "nextjs") {
// patch route.ts file
await copy("**", path.join(root, relativeEngineDestPath), {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-llama",
"version": "0.3.6",
"version": "0.3.10",
"description": "Create LlamaIndex-powered apps with one command",
"keywords": [
"rag",
+28
View File
@@ -177,6 +177,34 @@ export const askProQuestions = async (program: QuestionArgs) => {
program.observability = observability;
}
// Ask agents
if (program.template === "multiagent" && !program.agents) {
const { agents } = await prompts(
{
type: "select",
name: "agents",
message: "Which agents would you like to use?",
choices: [
{
title: "Financial report (generate a financial report)",
value: "financial_report",
},
{
title: "Form filling (fill missing value in a CSV file)",
value: "form_filling",
},
{
title: "Blog writer (Write a blog post)",
value: "blog_writer",
},
],
initial: 0,
},
questionHandlers,
);
program.agents = agents;
}
if (!program.modelConfig) {
const modelConfig = await askModelConfig({
openAiKey: program.openAiKey,
+16 -4
View File
@@ -10,6 +10,7 @@ type AppType =
| "rag"
| "code_artifact"
| "financial_report_agent"
| "form_filling"
| "extractor"
| "data_scientist";
@@ -35,8 +36,12 @@ export const askSimpleQuestions = async (
title: "Financial Report Generator (using Workflows)",
value: "financial_report_agent",
},
{
title: "Form Filler (using Workflows)",
value: "form_filling",
},
{ title: "Code Artifact Agent", value: "code_artifact" },
{ title: "Structured extraction", value: "extractor" },
{ title: "Information Extractor", value: "extractor" },
],
},
questionHandlers,
@@ -47,9 +52,8 @@ export const askSimpleQuestions = async (
let useLlamaCloud = false;
if (appType !== "extractor") {
// Default financial report agent use case only supports Python
// TODO: Add support for Typescript frameworks
if (appType !== "financial_report_agent") {
// TODO: Add TS support for form filling use case
if (appType !== "form_filling") {
const { language: newLanguage } = await prompts(
{
type: "select",
@@ -156,6 +160,14 @@ const convertAnswers = async (
frontend: true,
modelConfig: MODEL_GPT4o,
},
form_filling: {
template: "multiagent",
agents: "form_filling",
tools: getTools(["form_filling"]),
dataSources: EXAMPLE_10K_SEC_FILES,
frontend: true,
modelConfig: MODEL_GPT4o,
},
extractor: {
template: "extractor",
tools: [],
@@ -11,11 +11,11 @@ def get_publisher_tools() -> Tuple[List[FunctionTool], str, str]:
tools = []
# Get configured tools from the tools.yaml file
configured_tools = ToolFactory.from_env(map_result=True)
if "document_generator" in configured_tools.keys():
tools.extend(configured_tools["document_generator"])
if "generate_document" in configured_tools.keys():
tools.append(configured_tools["generate_document"])
prompt_instructions = dedent("""
Normally, reply the blog post content to the user directly.
But if user requested to generate a file, use the document_generator tool to generate the file and reply the link to the file.
But if user requested to generate a file, use the generate_document tool to generate the file and reply the link to the file.
""")
description = "Expert in publishing the blog post, able to publish the blog post in PDF or HTML format."
else:
@@ -42,11 +42,15 @@ def _get_research_tools(**kwargs) -> QueryEngineTool:
query_engine_tool = _create_query_engine_tool(**kwargs)
if query_engine_tool is not None:
tools.append(query_engine_tool)
researcher_tool_names = ["duckduckgo", "wikipedia.WikipediaToolSpec"]
researcher_tool_names = [
"duckduckgo_search",
"duckduckgo_image_search",
"wikipedia.WikipediaToolSpec",
]
configured_tools = ToolFactory.from_env(map_result=True)
for tool_name, tool in configured_tools.items():
if tool_name in researcher_tool_names:
tools.extend(tool)
tools.append(tool)
return tools
@@ -22,8 +22,8 @@ def _get_analyst_params() -> Tuple[List[type[FunctionTool]], str, str]:
description = "Expert in analyzing financial data"
configured_tools = ToolFactory.from_env(map_result=True)
# Check if the interpreter tool is configured
if "interpreter" in configured_tools.keys():
tools.extend(configured_tools["interpreter"])
if "interpret" in configured_tools.keys():
tools.append(configured_tools["interpret"])
prompt_instructions += dedent("""
You are able to visualize the financial data using code interpreter tool.
It's very useful to create and include visualizations to the report (make sure you include the right code and data for the visualization).
@@ -24,8 +24,8 @@ def _get_reporter_params(
"""
)
configured_tools = ToolFactory.from_env(map_result=True)
if "document_generator" in configured_tools: # type: ignore
tools.extend(configured_tools["document_generator"]) # type: ignore
if "generate_document" in configured_tools: # type: ignore
tools.append(configured_tools["generate_document"]) # type: ignore
prompt_instructions += (
"\nYou are also able to generate a file document (PDF/HTML) of the report."
)
@@ -0,0 +1,59 @@
This is a [LlamaIndex](https://www.llamaindex.ai/) multi-agents project using [Workflows](https://docs.llamaindex.ai/en/stable/understanding/workflows/).
## Getting Started
First, setup the environment with poetry:
> **_Note:_** This step is not needed if you are using the dev-container.
```shell
poetry install
```
Then check the parameters that have been pre-configured in the `.env` file in this directory.
Make sure you have the `OPENAI_API_KEY` set.
Second, run the development server:
```shell
poetry run python main.py
```
## Use Case: Filling Financial CSV Template
To reproduce the use case, start the [frontend](../frontend/README.md) and follow these steps in the frontend:
1. Upload the Apple and Tesla financial reports from the [data](./data) directory. Just send an empty message.
2. Upload the CSV file [sec_10k_template.csv](./sec_10k_template.csv) and send the message "Fill the missing cells in the CSV file".
The agent will fill the missing cells by retrieving the information from the uploaded financial reports and return a new CSV file with the filled cells.
### API endpoints
The example provides one streaming API endpoint `/api/chat`.
You can test the endpoint with the following curl request:
```
curl --location 'localhost:8000/api/chat' \
--header 'Content-Type: application/json' \
--data '{ "messages": [{ "role": "user", "content": "What can you do?" }] }'
```
You can start editing the API by modifying `app/api/routers/chat.py` or `app/agents/form_filling.py`. The API auto-updates as you save the files.
Open [http://localhost:8000/docs](http://localhost:8000/docs) with your browser to see the Swagger UI of the API.
The API allows CORS for all origins to simplify development. You can change this behavior by setting the `ENVIRONMENT` environment variable to `prod`:
```
ENVIRONMENT=prod poetry run python main.py
```
## Learn More
To learn more about LlamaIndex, take a look at the following resources:
- [LlamaIndex Documentation](https://docs.llamaindex.ai) - learn about LlamaIndex.
- [Workflows Introduction](https://docs.llamaindex.ai/en/stable/understanding/workflows/) - learn about LlamaIndex workflows.
You can check out [the LlamaIndex GitHub repository](https://github.com/run-llama/llama_index) - your feedback and contributions are welcome!
@@ -0,0 +1,397 @@
import os
import uuid
from enum import Enum
from typing import AsyncGenerator, List, Optional
from app.engine.index import get_index
from app.engine.tools import ToolFactory
from app.engine.tools.form_filling import CellValue, MissingCell
from llama_index.core import Settings
from llama_index.core.base.llms.types import ChatMessage, MessageRole
from llama_index.core.indices.vector_store import VectorStoreIndex
from llama_index.core.llms.function_calling import FunctionCallingLLM
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.tools import FunctionTool, QueryEngineTool, ToolSelection
from llama_index.core.tools.types import ToolOutput
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
from pydantic import Field
def create_workflow(
chat_history: Optional[List[ChatMessage]] = None, **kwargs
) -> Workflow:
index: VectorStoreIndex = get_index()
if index is None:
query_engine_tool = None
else:
top_k = int(os.getenv("TOP_K", 10))
query_engine = index.as_query_engine(similarity_top_k=top_k)
query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine)
configured_tools = ToolFactory.from_env(map_result=True)
extractor_tool = configured_tools.get("extract_questions")
filling_tool = configured_tools.get("fill_form")
if extractor_tool is None or filling_tool is None:
raise ValueError("Extractor or filling tool is not found!")
workflow = FormFillingWorkflow(
query_engine_tool=query_engine_tool,
extractor_tool=extractor_tool,
filling_tool=filling_tool,
chat_history=chat_history,
)
return workflow
class InputEvent(Event):
input: List[ChatMessage]
response: bool = False
class ExtractMissingCellsEvent(Event):
tool_call: ToolSelection
class FindAnswersEvent(Event):
missing_cells: list[MissingCell]
class FillEvent(Event):
tool_call: ToolSelection
class AgentRunEventType(Enum):
TEXT = "text"
PROGRESS = "progress"
class AgentRunEvent(Event):
name: str
msg: str
event_type: AgentRunEventType = Field(default=AgentRunEventType.TEXT)
data: Optional[dict] = None
def to_response(self) -> dict:
return {
"type": "agent",
"data": {
"agent": self.name,
"type": self.event_type.value,
"text": self.msg,
"data": self.data,
},
}
class FormFillingWorkflow(Workflow):
"""
A predefined workflow for filling missing cells in a CSV file.
Required tools:
- query_engine: A query engine to query for the answers to the questions.
- extract_question: Extract missing cells in a CSV file and generate questions to fill them.
- answer_question: Query for the answers to the questions.
Flow:
1. Extract missing cells in a CSV file and generate questions to fill them.
2. Query for the answers to the questions.
3. Fill the missing cells with the answers.
"""
_default_system_prompt = """
You are a helpful assistant who helps fill missing cells in a CSV file.
Only use provided data, never make up any information yourself. Fill N/A if the answer is not found.
"""
def __init__(
self,
query_engine_tool: QueryEngineTool,
extractor_tool: FunctionTool,
filling_tool: FunctionTool,
llm: Optional[FunctionCallingLLM] = None,
timeout: int = 360,
chat_history: Optional[List[ChatMessage]] = None,
system_prompt: Optional[str] = None,
):
super().__init__(timeout=timeout)
self.system_prompt = system_prompt or self._default_system_prompt
self.chat_history = chat_history or []
self.query_engine_tool = query_engine_tool
self.extractor_tool = extractor_tool
self.filling_tool = filling_tool
self.llm: FunctionCallingLLM = llm or Settings.llm
if not isinstance(self.llm, FunctionCallingLLM):
raise ValueError("FormFillingWorkflow only supports FunctionCallingLLM.")
self.memory = ChatMemoryBuffer.from_defaults(
llm=self.llm, chat_history=self.chat_history
)
@step()
async def start(self, ctx: Context, ev: StartEvent) -> InputEvent:
ctx.data["streaming"] = getattr(ev, "streaming", False)
ctx.data["input"] = ev.input
if self.system_prompt:
system_msg = ChatMessage(
role=MessageRole.SYSTEM, content=self.system_prompt
)
self.memory.put(system_msg)
user_input = ev.input
user_msg = ChatMessage(role=MessageRole.USER, content=user_input)
self.memory.put(user_msg)
chat_history = self.memory.get()
return InputEvent(input=chat_history)
@step(pass_context=True)
async def handle_llm_input( # type: ignore
self,
ctx: Context,
ev: InputEvent,
) -> ExtractMissingCellsEvent | FillEvent | StopEvent:
"""
Handle an LLM input and decide the next step.
"""
chat_history: list[ChatMessage] = ev.input
generator = self._tool_call_generator(chat_history)
# Check for immediate tool call
is_tool_call = await generator.__anext__()
if is_tool_call:
full_response = await generator.__anext__()
tool_calls = self.llm.get_tool_calls_from_response(full_response) # type: ignore
for tool_call in tool_calls:
if tool_call.tool_name == self.extractor_tool.metadata.get_name():
ctx.send_event(ExtractMissingCellsEvent(tool_call=tool_call))
elif tool_call.tool_name == self.filling_tool.metadata.get_name():
ctx.send_event(FillEvent(tool_call=tool_call))
else:
# If no tool call, return the generator
return StopEvent(result=generator)
@step()
async def extract_missing_cells(
self, ctx: Context, ev: ExtractMissingCellsEvent
) -> InputEvent | FindAnswersEvent:
"""
Extract missing cells in a CSV file and generate questions to fill them.
"""
ctx.write_event_to_stream(
AgentRunEvent(
name="Extractor",
msg="Extracting missing cells",
)
)
# Call the extract questions tool
response = self._call_tool(
ctx,
agent_name="Extractor",
tool=self.extractor_tool,
tool_selection=ev.tool_call,
)
if response.is_error:
return InputEvent(input=self.memory.get())
missing_cells = response.raw_output.get("missing_cells", [])
message = ChatMessage(
role=MessageRole.TOOL,
content=str(missing_cells),
additional_kwargs={
"tool_call_id": ev.tool_call.tool_id,
"name": ev.tool_call.tool_name,
},
)
self.memory.put(message)
if self.query_engine_tool is None:
# Fallback to input that query engine tool is not found so that cannot answer questions
self.memory.put(
ChatMessage(
role=MessageRole.ASSISTANT,
content="Extracted missing cells but query engine tool is not found so cannot answer questions. Ask user to upload file or connect to a knowledge base.",
)
)
return InputEvent(input=self.memory.get())
# Forward missing cells information to find answers step
return FindAnswersEvent(missing_cells=missing_cells)
@step()
async def find_answers(self, ctx: Context, ev: FindAnswersEvent) -> InputEvent:
"""
Call answer questions tool to query for the answers to the questions.
"""
ctx.write_event_to_stream(
AgentRunEvent(
name="Researcher",
msg="Finding answers for missing cells",
)
)
missing_cells = ev.missing_cells
# If missing cells information is not found, fallback to other tools
# It means that the extractor tool has not been called yet
# Fallback to input
if missing_cells is None:
ctx.write_event_to_stream(
AgentRunEvent(
name="Researcher",
msg="Error: Missing cells information not found. Fallback to other tools.",
)
)
message = ChatMessage(
role=MessageRole.TOOL,
content="Error: Missing cells information not found.",
additional_kwargs={
"tool_call_id": ev.tool_call.tool_id,
"name": ev.tool_call.tool_name,
},
)
self.memory.put(message)
return InputEvent(input=self.memory.get())
cell_values: list[CellValue] = []
# Iterate over missing cells and query for the answers
# and stream the progress
progress_id = str(uuid.uuid4())
total_steps = len(missing_cells)
for i, cell in enumerate(missing_cells):
if cell.question_to_answer is None:
continue
ctx.write_event_to_stream(
AgentRunEvent(
name="Researcher",
msg=f"Querying for: {cell.question_to_answer}",
event_type=AgentRunEventType.PROGRESS,
data={
"id": progress_id,
"total": total_steps,
"current": i,
},
)
)
# Call query engine tool directly
answer = await self.query_engine_tool.acall(query=cell.question_to_answer)
cell_values.append(
CellValue(
row_index=cell.row_index,
column_index=cell.column_index,
value=str(answer),
)
)
self.memory.put(
ChatMessage(
role=MessageRole.ASSISTANT,
content=str(cell_values),
)
)
return InputEvent(input=self.memory.get())
@step()
async def fill_cells(self, ctx: Context, ev: FillEvent) -> InputEvent:
"""
Call fill cells tool to fill the missing cells with the answers.
"""
ctx.write_event_to_stream(
AgentRunEvent(
name="Processor",
msg="Filling missing cells",
)
)
# Call the fill cells tool
result = self._call_tool(
ctx,
agent_name="Processor",
tool=self.filling_tool,
tool_selection=ev.tool_call,
)
if result.is_error:
return InputEvent(input=self.memory.get())
message = ChatMessage(
role=MessageRole.TOOL,
content=str(result.raw_output),
additional_kwargs={
"tool_call_id": ev.tool_call.tool_id,
"name": ev.tool_call.tool_name,
},
)
self.memory.put(message)
return InputEvent(input=self.memory.get(), response=True)
async def _tool_call_generator(
self, chat_history: list[ChatMessage]
) -> AsyncGenerator[ChatMessage | bool, None]:
response_stream = await self.llm.astream_chat_with_tools(
[self.extractor_tool, self.filling_tool],
chat_history=chat_history,
)
full_response = None
yielded_indicator = False
async for chunk in response_stream:
if "tool_calls" not in chunk.message.additional_kwargs:
# Yield a boolean to indicate whether the response is a tool call
if not yielded_indicator:
yield False
yielded_indicator = True
# if not a tool call, yield the chunks!
yield chunk
elif not yielded_indicator:
# Yield the indicator for a tool call
yield True
yielded_indicator = True
full_response = chunk
# Write the full response to memory and yield it
if full_response:
self.memory.put(full_response.message)
yield full_response
def _call_tool(
self,
ctx: Context,
agent_name: str,
tool: FunctionTool,
tool_selection: ToolSelection,
) -> ToolOutput:
"""
Safely call a tool and handle errors.
"""
try:
response: ToolOutput = tool.call(**tool_selection.tool_kwargs)
return response
except Exception as e:
ctx.write_event_to_stream(
AgentRunEvent(
name=agent_name,
msg=f"Error: {str(e)}",
)
)
message = ChatMessage(
role=MessageRole.TOOL,
content=f"Error: {str(e)}",
additional_kwargs={
"tool_call_id": tool_selection.tool_id,
"name": tool.metadata.get_name(),
},
)
self.memory.put(message)
return ToolOutput(
content=f"Error: {str(e)}",
tool_name=tool.metadata.get_name(),
raw_input=tool_selection.tool_kwargs,
raw_output=None,
is_error=True,
)
@@ -0,0 +1,11 @@
from typing import List, Optional
from app.agents.form_filling import create_workflow
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.workflow import Workflow
def get_chat_engine(
chat_history: Optional[List[ChatMessage]] = None, **kwargs
) -> Workflow:
return create_workflow(chat_history=chat_history, **kwargs)
@@ -0,0 +1,17 @@
Parameter,2023 Apple (AAPL),2023 Tesla (TSLA)
Revenue,,
Net Income,,
Earnings Per Share (EPS),,
Debt-to-Equity Ratio,,
Current Ratio,,
Gross Margin,,
Operating Margin,,
Net Profit Margin,,
Inventory Turnover,,
Accounts Receivable Turnover,,
Capital Expenditure,,
Research and Development Expense,,
Market Cap,,
Price to Earnings Ratio,,
Dividend Yield,,
Year-over-Year Growth Rate,,
1 Parameter 2023 Apple (AAPL) 2023 Tesla (TSLA)
2 Revenue
3 Net Income
4 Earnings Per Share (EPS)
5 Debt-to-Equity Ratio
6 Current Ratio
7 Gross Margin
8 Operating Margin
9 Net Profit Margin
10 Inventory Turnover
11 Accounts Receivable Turnover
12 Capital Expenditure
13 Research and Development Expense
14 Market Cap
15 Price to Earnings Ratio
16 Dividend Yield
17 Year-over-Year Growth Rate
@@ -0,0 +1,65 @@
import { ChatMessage } from "llamaindex";
import { FunctionCallingAgent } from "./single-agent";
import { getQueryEngineTools, lookupTools } from "./tools";
export const createResearcher = async (
chatHistory: ChatMessage[],
params?: any,
) => {
const queryEngineTools = await getQueryEngineTools(params);
if (!queryEngineTools) {
throw new Error("Query engine tool not found");
}
return new FunctionCallingAgent({
name: "researcher",
tools: queryEngineTools,
systemPrompt: `You are a researcher agent. You are responsible for retrieving information from the corpus.
## Instructions:
+ Don't synthesize the information, just return the whole retrieved information.
+ Don't need to retrieve the information that is already provided in the chat history and respond with: "There is no new information, please reuse the information from the conversation."
`,
chatHistory,
});
};
export const createAnalyst = async (chatHistory: ChatMessage[]) => {
let systemPrompt = `You are an expert in analyzing financial data.
You are given a task and a set of financial data to analyze. Your task is to analyze the financial data and return a report.
Your response should include a detailed analysis of the financial data, including any trends, patterns, or insights that you find.
Construct the analysis in textual format; including tables would be great!
Don't need to synthesize the data, just analyze and provide your findings.
Always use the provided information, don't make up any information yourself.`;
const tools = await lookupTools(["interpreter"]);
if (tools.length > 0) {
systemPrompt = `${systemPrompt}
You are able to visualize the financial data using code interpreter tool.
It's very useful to create and include visualizations in the report. Never include any code in the report, just the visualization.`;
}
return new FunctionCallingAgent({
name: "analyst",
tools: tools,
chatHistory,
});
};
export const createReporter = async (chatHistory: ChatMessage[]) => {
const tools = await lookupTools(["document_generator"]);
let systemPrompt = `You are a report generation assistant tasked with producing a well-formatted report given parsed context.
Given a comprehensive analysis of the user request, your task is to synthesize the information and return a well-formatted report.
## Instructions
You are responsible for representing the analysis in a well-formatted report. If tables or visualizations are provided, add them to the most relevant sections.
Finally, the report should be presented in markdown format.`;
if (tools.length > 0) {
systemPrompt = `${systemPrompt}.
You are also able to generate an HTML file of the report.`;
}
return new FunctionCallingAgent({
name: "reporter",
tools: tools,
systemPrompt: systemPrompt,
chatHistory,
});
};
@@ -0,0 +1,159 @@
import {
Context,
StartEvent,
StopEvent,
Workflow,
WorkflowEvent,
} from "@llamaindex/core/workflow";
import { Message } from "ai";
import { ChatMessage, ChatResponseChunk, Settings } from "llamaindex";
import { getAnnotations } from "../llamaindex/streaming/annotations";
import { createAnalyst, createReporter, createResearcher } from "./agents";
import { AgentInput, AgentRunEvent } from "./type";
const TIMEOUT = 360 * 1000;
const MAX_ATTEMPTS = 2;
class ResearchEvent extends WorkflowEvent<{ input: string }> {}
class AnalyzeEvent extends WorkflowEvent<{ input: string }> {}
class ReportEvent extends WorkflowEvent<{ input: string }> {}
const prepareChatHistory = (chatHistory: Message[]): ChatMessage[] => {
// By default, the chat history only contains the assistant and user messages
// all the agents messages are stored in annotation data which is not visible to the LLM
const MAX_AGENT_MESSAGES = 10;
const agentAnnotations = getAnnotations<{ agent: string; text: string }>(
chatHistory,
{ role: "assistant", type: "agent" },
).slice(-MAX_AGENT_MESSAGES);
const agentMessages = agentAnnotations
.map(
(annotation) =>
`\n<${annotation.data.agent}>\n${annotation.data.text}\n</${annotation.data.agent}>`,
)
.join("\n");
const agentContent = agentMessages
? "Here is the previous conversation of agents:\n" + agentMessages
: "";
if (agentContent) {
const agentMessage: ChatMessage = {
role: "assistant",
content: agentContent,
};
return [
...chatHistory.slice(0, -1),
agentMessage,
chatHistory.slice(-1)[0],
] as ChatMessage[];
}
return chatHistory as ChatMessage[];
};
export const createWorkflow = (messages: Message[], params?: any) => {
const chatHistoryWithAgentMessages = prepareChatHistory(messages);
const runAgent = async (
context: Context,
agent: Workflow,
input: AgentInput,
) => {
const run = agent.run(new StartEvent({ input }));
for await (const event of agent.streamEvents()) {
if (event.data instanceof AgentRunEvent) {
context.writeEventToStream(event.data);
}
}
return await run;
};
const start = async (context: Context, ev: StartEvent) => {
context.set("task", ev.data.input);
const chatHistoryStr = chatHistoryWithAgentMessages
.map((msg) => `${msg.role}: ${msg.content}`)
.join("\n");
// Decision-making process
const decision = await decideWorkflow(ev.data.input, chatHistoryStr);
if (decision !== "publish") {
return new ResearchEvent({
input: `Research for this task: ${ev.data.input}`,
});
} else {
return new ReportEvent({
input: `Publish content based on the chat history\n${chatHistoryStr}\n\n and task: ${ev.data.input}`,
});
}
};
const decideWorkflow = async (task: string, chatHistoryStr: string) => {
const llm = Settings.llm;
const prompt = `You are an expert in decision-making, helping people write and publish blog posts.
If the user is asking for a file or to publish content, respond with 'publish'.
If the user requests to write or update a blog post, respond with 'not_publish'.
Here is the chat history:
${chatHistoryStr}
The current user request is:
${task}
Given the chat history and the new user request, decide whether to publish based on existing information.
Decision (respond with either 'not_publish' or 'publish'):`;
const output = await llm.complete({ prompt: prompt });
const decision = output.text.trim().toLowerCase();
return decision === "publish" ? "publish" : "research";
};
const research = async (context: Context, ev: ResearchEvent) => {
const researcher = await createResearcher(
chatHistoryWithAgentMessages,
params,
);
const researchRes = await runAgent(context, researcher, {
message: ev.data.input,
});
const researchResult = researchRes.data.result;
return new AnalyzeEvent({
input: `Write a blog post given this task: ${context.get("task")} using this research content: ${researchResult}`,
});
};
const analyze = async (context: Context, ev: AnalyzeEvent) => {
const analyst = await createAnalyst(chatHistoryWithAgentMessages);
const analyzeRes = await runAgent(context, analyst, {
message: ev.data.input,
});
return new ReportEvent({
input: `Publish content based on the chat history\n${analyzeRes.data.result}\n\n and task: ${ev.data.input}`,
});
};
const report = async (context: Context, ev: ReportEvent) => {
const reporter = await createReporter(chatHistoryWithAgentMessages);
const reportResult = await runAgent(context, reporter, {
message: `${ev.data.input}`,
streaming: true,
});
return reportResult as unknown as StopEvent<
AsyncGenerator<ChatResponseChunk>
>;
};
const workflow = new Workflow({ timeout: TIMEOUT, validate: true });
workflow.addStep(StartEvent, start, {
outputs: [ResearchEvent, ReportEvent],
});
workflow.addStep(ResearchEvent, research, { outputs: AnalyzeEvent });
workflow.addStep(AnalyzeEvent, analyze, { outputs: ReportEvent });
workflow.addStep(ReportEvent, report, { outputs: StopEvent });
return workflow;
};
@@ -0,0 +1,86 @@
import fs from "fs/promises";
import { BaseToolWithCall, LlamaCloudIndex, QueryEngineTool } from "llamaindex";
import path from "path";
import { getDataSource } from "../engine";
import { createTools } from "../engine/tools/index";
export const getQueryEngineTools = async (
params?: any,
): Promise<QueryEngineTool[] | null> => {
const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined;
const index = await getDataSource(params);
if (!index) {
return null;
}
// index is LlamaCloudIndex use two query engine tools
if (index instanceof LlamaCloudIndex) {
return [
new QueryEngineTool({
queryEngine: index.asQueryEngine({
similarityTopK: topK,
retrieval_mode: "files_via_content",
}),
metadata: {
name: "document_retriever",
description: `Document retriever that retrieves entire documents from the corpus.
ONLY use for research questions that may require searching over entire research reports.
Will be slower and more expensive than chunk-level retrieval but may be necessary.`,
},
}),
new QueryEngineTool({
queryEngine: index.asQueryEngine({
similarityTopK: topK,
retrieval_mode: "chunks",
}),
metadata: {
name: "chunk_retriever",
description: `Retrieves a small set of relevant document chunks from the corpus.
Use for research questions that want to look up specific facts from the knowledge corpus,
and need entire documents.`,
},
}),
];
} else {
return [
new QueryEngineTool({
queryEngine: (index as any).asQueryEngine({
similarityTopK: topK,
}),
metadata: {
name: "retriever",
description: `Use this tool to retrieve information about the text corpus from the index.`,
},
}),
];
}
};
export const getAvailableTools = async () => {
const configFile = path.join("config", "tools.json");
let toolConfig: any;
const tools: BaseToolWithCall[] = [];
try {
toolConfig = JSON.parse(await fs.readFile(configFile, "utf8"));
} catch (e) {
console.info(`Could not read ${configFile} file. Using no tools.`);
}
if (toolConfig) {
tools.push(...(await createTools(toolConfig)));
}
const queryEngineTools = await getQueryEngineTools();
if (queryEngineTools) {
tools.push(...queryEngineTools);
}
return tools;
};
export const lookupTools = async (
toolNames: string[],
): Promise<BaseToolWithCall[]> => {
const availableTools = await getAvailableTools();
return availableTools.filter((tool) =>
toolNames.includes(tool.metadata.name),
);
};
@@ -56,7 +56,7 @@ class ToolFactory:
A dictionary of tool names to lists of FunctionTools if map_result is True,
otherwise a list of FunctionTools.
"""
tools: Union[Dict[str, List[FunctionTool]], List[FunctionTool]] = (
tools: Union[Dict[str, FunctionTool], List[FunctionTool]] = (
{} if map_result else []
)
@@ -69,7 +69,9 @@ class ToolFactory:
tool_type, tool_name, config
)
if map_result:
tools[tool_name] = loaded_tools # type: ignore
tools.update( # type: ignore
{tool.metadata.name: tool for tool in loaded_tools}
)
else:
tools.extend(loaded_tools) # type: ignore
@@ -0,0 +1,224 @@
import logging
import os
import uuid
from textwrap import dedent
from typing import Optional
import pandas as pd
from app.services.file import FileService
from llama_index.core import Settings
from llama_index.core.prompts import PromptTemplate
from llama_index.core.tools import FunctionTool
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class MissingCell(BaseModel):
"""
A missing cell in a table.
"""
row_index: int = Field(description="The index of the row of the missing cell")
column_index: int = Field(description="The index of the column of the missing cell")
question_to_answer: str = Field(
description="The question to answer to fill the missing cell"
)
class MissingCells(BaseModel):
"""
A list of missing cells.
"""
missing_cells: list[MissingCell] = Field(description="The missing cells")
class CellValue(BaseModel):
row_index: int = Field(description="The row index of the cell")
column_index: int = Field(description="The column index of the cell")
value: str = Field(
description="The value of the cell. Should be a concise value (numerical value or specific value)"
)
class FormFillingTool:
"""
Fill out missing cells in a CSV file using information from the knowledge base.
"""
save_dir: str = os.path.join("output", "tools")
# Default prompt for extracting questions
# Replace the default prompt with a custom prompt by setting the EXTRACT_QUESTIONS_PROMPT environment variable.
_default_extract_questions_prompt = dedent(
"""
You are a data analyst. You are given a table with missing cells.
Your task is to identify the missing cells and the questions needed to fill them.
IMPORTANT: Column indices should be 0-based, where the first data column is index 1
(index 0 is typically the row names/index column).
# Instructions:
- Understand the entire content of the table and the topics of the table.
- Identify the missing cells and the meaning of the data in the cells.
- For each missing cell, provide the row index and the correct column index (remember: first data column is 1).
- For each missing cell, provide the question needed to fill the cell (it's important to provide the question that is relevant to the topic of the table).
- Since the cell's value should be concise, the question should request a numerical answer or a specific value.
# Example:
# | | Name | Age | City |
# |----|------|-----|------|
# | 0 | John | | Paris|
# | 1 | Mary | | |
# | 2 | | 30 | |
#
# Your thoughts:
# - The table is about people's names, ages, and cities.
# - Row: 1, Column: 1 (Age column), Question: "How old is Mary? Please provide only the numerical answer."
# - Row: 1, Column: 2 (City column), Question: "In which city does Mary live? Please provide only the city name."
Please provide your answer in the requested format.
# Here is your task:
- Table content:
{table_content}
- Your answer:
"""
)
def extract_questions(
self,
file_path: Optional[str] = None,
file_content: Optional[str] = None,
) -> dict:
"""
Use this tool to extract missing cells in a CSV file and generate questions to fill them.
Pass either the path to the CSV file or the content of the CSV file.
Args:
file_path (Optional[str]): The local file path to the CSV file to extract missing cells from (Don't pass a sandbox path).
file_content (Optional[str]): The content of the CSV file to extract missing cells from.
Returns:
dict: A dictionary containing the missing cells and their corresponding questions.
"""
extract_questions_prompt = os.getenv(
"EXTRACT_QUESTIONS_PROMPT", self._default_extract_questions_prompt
)
if file_path is None and file_content is None:
raise ValueError("Either `file_path` or `file_content` must be provided")
table_content = None
if file_path:
file_name, file_extension = self._get_file_name_and_extension(
file_path, file_content
)
try:
df = pd.read_csv(file_path)
except FileNotFoundError as e:
return {
"error": str(e),
"message": "Please check and update the file path and ensure it's a local path - not a sandbox path.",
}
table_content = df.to_markdown()
if table_content is None:
raise ValueError("Could not convert the table to markdown")
if file_content:
table_content = file_content
if table_content is None:
raise ValueError("Table content not found")
response: MissingCells = Settings.llm.structured_predict(
output_cls=MissingCells,
prompt=PromptTemplate(extract_questions_prompt),
table_content=table_content,
)
return response.model_dump()
def fill_form(
self,
cell_values: list[CellValue],
file_path: Optional[str] = None,
file_content: Optional[str] = None,
) -> dict:
"""
Use this tool to fill cell values into a CSV file.
Requires cell values to be used for filling out, as well as either the path to the CSV file or the content of the CSV file.
Args:
cell_values (list[CellValue]): The cell values used to fill out the CSV file (call `extract_questions` and query engine to construct the cell values).
file_path (Optional[str]): The local file path to the CSV file that should be filled out (not as sandbox path).
file_content (Optional[str]): The content of the CSV file that should be filled out.
Returns:
dict: A dictionary containing the content and metadata of the filled-out file.
"""
file_name, file_extension = self._get_file_name_and_extension(
file_path, file_content
)
df = pd.read_csv(file_path)
# Fill the dataframe with the cell values
filled_df = df.copy()
for cell_value in cell_values:
if not isinstance(cell_value, CellValue):
cell_value = CellValue(**cell_value)
filled_df.iloc[cell_value.row_index, cell_value.column_index] = (
cell_value.value
)
# Save the filled table to a new CSV file
csv_content: str = filled_df.to_csv(index=False)
file_metadata = FileService.save_file(
content=csv_content,
file_name=f"{file_name}_filled.csv",
save_dir=self.save_dir,
)
new_content: str = filled_df.to_markdown()
result = {
"filled_content": new_content,
"filled_file": file_metadata,
}
return result
def _get_file_name_and_extension(
self, file_path: Optional[str], file_content: Optional[str]
) -> tuple[str, str]:
if file_path is None and file_content is None:
raise ValueError("Either `file_path` or `file_content` must be provided")
if file_path is None:
file_name = str(uuid.uuid4())
file_extension = ".csv"
else:
file_name, file_extension = os.path.splitext(file_path)
if file_extension != ".csv":
raise ValueError("Form filling is only supported for CSV files")
return file_name, file_extension
def _save_output(self, file_name: str, output: str) -> dict:
"""
Save the output to a file.
"""
file_metadata = FileService.save_file(
content=output,
file_name=file_name,
save_dir=self.save_dir,
)
return file_metadata.model_dump()
def get_tools(**kwargs):
tool = FormFillingTool()
return [
FunctionTool.from_defaults(tool.extract_questions),
FunctionTool.from_defaults(tool.fill_form),
]
@@ -49,7 +49,11 @@ export async function uploadDocument(
}
} else {
// run the pipeline for other vector store indexes
const documents: Document[] = await parseFile(fileBuffer, name, mimeType);
const documents: Document[] = await parseFile(
fileBuffer,
fileMetadata.name,
mimeType,
);
documentIds = await runPipeline(index, documents);
}
@@ -1,7 +1,7 @@
import {
FILE_EXT_TO_READER,
SimpleDirectoryReader,
} from "llamaindex/readers/SimpleDirectoryReader";
} from "llamaindex/readers/index";
export const DATA_DIR = "./data";
@@ -2,7 +2,7 @@ import { LlamaParseReader } from "llamaindex";
import {
FILE_EXT_TO_READER,
SimpleDirectoryReader,
} from "llamaindex/readers/SimpleDirectoryReader";
} from "llamaindex/readers/index";
export const DATA_DIR = "./data";
@@ -1,12 +1,11 @@
import asyncio
import json
import logging
from typing import AsyncGenerator, List
from typing import AsyncGenerator, Awaitable, List
from aiostream import stream
from app.api.routers.models import ChatData, Message
from app.api.services.suggestion import NextQuestionSuggestion
from app.workflows.single import AgentRunEvent, AgentRunResult
from fastapi import Request
from fastapi.responses import StreamingResponse
@@ -55,8 +54,8 @@ class VercelStreamResponse(StreamingResponse):
self,
request: Request,
chat_data: ChatData,
event_handler: AgentRunResult | AsyncGenerator,
events: AsyncGenerator[AgentRunEvent, None],
event_handler: Awaitable,
events: AsyncGenerator,
verbose: bool = True,
):
# Yield the text response
@@ -64,15 +63,17 @@ class VercelStreamResponse(StreamingResponse):
result = await event_handler
final_response = ""
if isinstance(result, AgentRunResult):
for token in result.response.message.content:
final_response += token
yield self.convert_text(token)
if isinstance(result, AsyncGenerator):
async for token in result:
final_response += token.delta
final_response += str(token.delta)
yield self.convert_text(token.delta)
else:
if hasattr(result, "response"):
content = result.response.message.content
if content:
for token in content:
final_response += str(token)
yield self.convert_text(token)
# Generate next questions if next question prompt is configured
question_data = await self._generate_next_questions(
@@ -86,7 +87,7 @@ class VercelStreamResponse(StreamingResponse):
# Yield the events from the event handler
async def _event_generator():
async for event in events:
event_response = self._event_to_response(event)
event_response = event.to_response()
if verbose:
logger.debug(event_response)
if event_response is not None:
@@ -95,13 +96,6 @@ class VercelStreamResponse(StreamingResponse):
combine = stream.merge(_chat_response_generator(), _event_generator())
return combine
@staticmethod
def _event_to_response(event: AgentRunEvent) -> dict:
return {
"type": "agent",
"data": {"agent": event.name, "text": event.msg},
}
@classmethod
def convert_text(cls, token: str):
# Escape newlines and double quotes to avoid breaking the stream
@@ -1,4 +1,5 @@
from abc import abstractmethod
from enum import Enum
from typing import Any, AsyncGenerator, List, Optional
from llama_index.core.llms import ChatMessage, ChatResponse
@@ -15,7 +16,7 @@ from llama_index.core.workflow import (
Workflow,
step,
)
from pydantic import BaseModel
from pydantic import BaseModel, Field
class InputEvent(Event):
@@ -26,17 +27,27 @@ class ToolCallEvent(Event):
tool_calls: list[ToolSelection]
class AgentRunEventType(Enum):
TEXT = "text"
PROGRESS = "progress"
class AgentRunEvent(Event):
name: str
_msg: str
msg: str
event_type: AgentRunEventType = Field(default=AgentRunEventType.TEXT)
data: Optional[dict] = None
@property
def msg(self):
return self._msg
@msg.setter
def msg(self, value):
self._msg = value
def to_response(self) -> dict:
return {
"type": "agent",
"data": {
"name": self.name,
"type": self.event_type.value,
"msg": self.msg,
"data": self.data,
},
}
class AgentRunResult(BaseModel):
@@ -182,7 +182,9 @@ export class FunctionCallingAgent extends Workflow {
// TODO: make logger optional in callTool in framework
const toolOutput = await callTool(targetTool, call, {
log: () => {},
error: console.error.bind(console),
error: (...args: unknown[]) => {
console.error(`[Tool ${call.name} Error]:`, ...args);
},
warn: () => {},
});
toolMsgs.push({
@@ -15,13 +15,12 @@
"dev": "concurrently \"tsup index.ts --format esm --dts --watch\" \"nodemon --watch dist/index.js\""
},
"dependencies": {
"@llamaindex/core": "^0.2.6",
"ai": "3.3.42",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"duck-duck-scrape": "^2.2.5",
"express": "^4.18.2",
"llamaindex": "0.6.22",
"llamaindex": "0.8.2",
"pdf2json": "3.0.5",
"ajv": "^8.12.0",
"@e2b/code-interpreter": "0.0.9-beta.3",
@@ -60,9 +60,11 @@ class AnnotationFileData(BaseModel):
# Include document IDs if it's available
if file.refs is not None:
default_content += f"Document IDs: {file.refs}\n"
# Include sandbox file path
# file path
sandbox_file_path = f"/tmp/{file.name}"
local_file_path = f"output/uploaded/{file.name}"
default_content += f"Sandbox file path (instruction: only use sandbox path for artifact or code interpreter tool): {sandbox_file_path}\n"
default_content += f"Local file path (instruction: Use for local tools: form filling, extractor): {local_file_path}\n"
return default_content
def to_llm_content(self) -> Optional[str]:
@@ -128,24 +130,29 @@ class ChatData(BaseModel):
def get_last_message_content(self) -> str:
"""
Get the content of the last message along with the data content if available.
Fallback to use data content from previous messages
Get the content of the last message along with the data content from all user messages
"""
if len(self.messages) == 0:
raise ValueError("There is not any message in the chat")
last_message = self.messages[-1]
message_content = last_message.content
for message in reversed(self.messages):
# Collect annotation contents from all user messages
all_annotation_contents: List[str] = []
for message in self.messages:
if message.role == MessageRole.USER and message.annotations is not None:
annotation_contents = filter(
None,
[annotation.to_content() for annotation in message.annotations],
)
if not annotation_contents:
continue
annotation_text = "\n".join(annotation_contents)
message_content = f"{message_content}\n{annotation_text}"
break
all_annotation_contents.extend(annotation_contents)
# Add all annotation contents if any exist
if len(all_annotation_contents) > 0:
annotation_text = "\n".join(all_annotation_contents)
message_content = f"{message_content}\n{annotation_text}"
return message_content
def _get_agent_messages(self, max_messages: int = 10) -> List[str]:
@@ -6,7 +6,7 @@ import re
import uuid
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import List, Optional, Tuple
from llama_index.core import VectorStoreIndex
from llama_index.core.ingestion import IngestionPipeline
@@ -14,7 +14,6 @@ from llama_index.core.readers.file.base import (
_try_loading_included_file_formats as get_file_loaders_map,
)
from llama_index.core.schema import Document
from llama_index.core.tools.function_tool import FunctionTool
from llama_index.indices.managed.llama_cloud.base import LlamaCloudIndex
from llama_index.readers.file import FlatReader
from pydantic import BaseModel, Field
@@ -78,10 +77,8 @@ class FileService:
save_dir=PRIVATE_STORE_PATH,
)
tools = _get_available_tools()
code_executor_tools = ["interpreter", "artifact"]
# If the file is CSV and there is a code executor tool, we don't need to index.
if extension == "csv" and any(tool in tools for tool in code_executor_tools):
# Don't index csv files (they are handled by tools)
if extension == "csv":
return document_file
else:
# Insert the file into the index and update document ids to the file metadata
@@ -283,18 +280,3 @@ def _default_file_loaders_map():
default_loaders[".txt"] = FlatReader
default_loaders[".csv"] = FlatReader
return default_loaders
def _get_available_tools() -> Dict[str, List[FunctionTool]]:
try:
from app.engine.tools import ToolFactory # type: ignore
except ImportError:
logger.warning("ToolFactory not found, no tools will be available")
return {}
try:
tools = ToolFactory.from_env(map_result=True)
return tools # type: ignore
except Exception as e:
logger.error(f"Error loading tools from environment: {str(e)}")
raise ValueError(f"Failed to get available tools: {str(e)}") from e
@@ -1,57 +1,26 @@
"use client";
import { ChatSection as ChatSectionUI } from "@llamaindex/chat-ui";
import "@llamaindex/chat-ui/styles/code.css";
import "@llamaindex/chat-ui/styles/katex.css";
import { useChat } from "ai/react";
import { useState } from "react";
import { ChatInput, ChatMessages } from "./ui/chat";
import CustomChatInput from "./ui/chat/chat-input";
import CustomChatMessages from "./ui/chat/chat-messages";
import { useClientConfig } from "./ui/chat/hooks/use-config";
export default function ChatSection() {
const { backend } = useClientConfig();
const [requestData, setRequestData] = useState<any>();
const {
messages,
input,
isLoading,
handleSubmit,
handleInputChange,
reload,
stop,
append,
setInput,
} = useChat({
body: { data: requestData },
const handler = useChat({
api: `${backend}/api/chat`,
headers: {
"Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26
},
onError: (error: unknown) => {
if (!(error instanceof Error)) throw error;
const message = JSON.parse(error.message);
alert(message.detail);
alert(JSON.parse(error.message).detail);
},
sendExtraMessageFields: true,
});
return (
<div className="space-y-4 w-full h-full flex flex-col">
<ChatMessages
messages={messages}
isLoading={isLoading}
reload={reload}
stop={stop}
append={append}
/>
<ChatInput
input={input}
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
isLoading={isLoading}
messages={messages}
append={append}
setInput={setInput}
requestParams={{ params: requestData }}
setRequestData={setRequestData}
/>
</div>
<ChatSectionUI handler={handler} className="w-full h-full">
<CustomChatMessages />
<CustomChatInput />
</ChatSectionUI>
);
}
@@ -1 +0,0 @@
Using the chat component from https://github.com/marcusschiesser/ui (based on https://ui.shadcn.com/)
@@ -1,28 +0,0 @@
import { PauseCircle, RefreshCw } from "lucide-react";
import { Button } from "../button";
import { ChatHandler } from "./chat.interface";
export default function ChatActions(
props: Pick<ChatHandler, "stop" | "reload"> & {
showReload?: boolean;
showStop?: boolean;
},
) {
return (
<div className="space-x-4">
{props.showStop && (
<Button variant="outline" size="sm" onClick={props.stop}>
<PauseCircle className="mr-2 h-4 w-4" />
Stop generating
</Button>
)}
{props.showReload && (
<Button variant="outline" size="sm" onClick={props.reload}>
<RefreshCw className="mr-2 h-4 w-4" />
Regenerate
</Button>
)}
</div>
);
}
@@ -1,8 +1,10 @@
import { useChatMessage } from "@llamaindex/chat-ui";
import { User2 } from "lucide-react";
import Image from "next/image";
export default function ChatAvatar({ role }: { role: string }) {
if (role === "user") {
export function ChatMessageAvatar() {
const { message } = useChatMessage();
if (message.role === "user") {
return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-background shadow">
<User2 className="h-4 w-4" />
@@ -1,34 +1,13 @@
import { JSONValue } from "ai";
import React from "react";
import { DocumentFile } from ".";
import { Button } from "../button";
import { DocumentPreview } from "../document-preview";
import FileUploader from "../file-uploader";
import { Textarea } from "../textarea";
import UploadImagePreview from "../upload-image-preview";
import { ChatHandler } from "./chat.interface";
import { useFile } from "./hooks/use-file";
import { LlamaCloudSelector } from "./widgets/LlamaCloudSelector";
"use client";
const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "csv", "pdf", "txt", "docx"];
import { ChatInput, useChatUI, useFile } from "@llamaindex/chat-ui";
import { DocumentPreview, ImagePreview } from "@llamaindex/chat-ui/widgets";
import { LlamaCloudSelector } from "./custom/llama-cloud-selector";
import { useClientConfig } from "./hooks/use-config";
export default function ChatInput(
props: Pick<
ChatHandler,
| "isLoading"
| "input"
| "onFileUpload"
| "onFileError"
| "handleSubmit"
| "handleInputChange"
| "messages"
| "setInput"
| "append"
> & {
requestParams?: any;
setRequestData?: React.Dispatch<any>;
},
) {
export default function CustomChatInput() {
const { requestData, isLoading, input } = useChatUI();
const { backend } = useClientConfig();
const {
imageUrl,
setImageUrl,
@@ -37,101 +16,65 @@ export default function ChatInput(
removeDoc,
reset,
getAnnotations,
} = useFile();
// default submit function does not handle including annotations in the message
// so we need to use append function to submit new message with annotations
const handleSubmitWithAnnotations = (
e: React.FormEvent<HTMLFormElement>,
annotations: JSONValue[] | undefined,
) => {
e.preventDefault();
props.append!({
content: props.input,
role: "user",
createdAt: new Date(),
annotations,
});
props.setInput!("");
};
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const annotations = getAnnotations();
if (annotations.length) {
handleSubmitWithAnnotations(e, annotations);
return reset();
}
props.handleSubmit(e);
};
} = useFile({ uploadAPI: `${backend}/api/chat/upload` });
/**
* Handles file uploads. Overwrite to hook into the file upload behavior.
* @param file The file to upload
*/
const handleUploadFile = async (file: File) => {
// There's already an image uploaded, only allow one image at a time
if (imageUrl) {
alert("You can only upload one image at a time.");
return;
}
try {
await uploadFile(file, props.requestParams);
props.onFileUpload?.(file);
// Upload the file and send with it the current request data
await uploadFile(file, requestData);
} catch (error: any) {
const onFileUploadError = props.onFileError || window.alert;
onFileUploadError(error.message);
// Show error message if upload fails
alert(error.message);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit(e as unknown as React.FormEvent<HTMLFormElement>);
}
};
// Get references to the upload files in message annotations format, see https://github.com/run-llama/chat-ui/blob/main/packages/chat-ui/src/hook/use-file.tsx#L56
const annotations = getAnnotations();
return (
<form
onSubmit={onSubmit}
className="rounded-xl bg-white p-4 shadow-xl space-y-4 shrink-0"
<ChatInput
className="shadow-xl rounded-xl"
resetUploadedFiles={reset}
annotations={annotations}
>
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={() => setImageUrl(null)} />
)}
{files.length > 0 && (
<div className="flex gap-4 w-full overflow-auto py-2">
{files.map((file: DocumentFile) => (
<DocumentPreview
key={file.id}
file={file}
onRemove={() => removeDoc(file)}
/>
))}
</div>
)}
<div className="flex w-full items-start justify-between gap-4 ">
<Textarea
id="chat-input"
autoFocus
name="message"
placeholder="Type a message"
className="flex-1 min-h-0 h-[40px]"
value={props.input}
onChange={props.handleInputChange}
onKeyDown={handleKeyDown}
/>
<FileUploader
onFileUpload={handleUploadFile}
onFileError={props.onFileError}
config={{
allowedExtensions: ALLOWED_EXTENSIONS,
disabled: props.isLoading,
}}
/>
{process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" &&
props.setRequestData && (
<LlamaCloudSelector setRequestData={props.setRequestData} />
)}
<Button type="submit" disabled={props.isLoading || !props.input.trim()}>
Send message
</Button>
<div>
{/* Image preview section */}
{imageUrl && (
<ImagePreview url={imageUrl} onRemove={() => setImageUrl(null)} />
)}
{/* Document previews section */}
{files.length > 0 && (
<div className="flex gap-4 w-full overflow-auto py-2">
{files.map((file) => (
<DocumentPreview
key={file.id}
file={file}
onRemove={() => removeDoc(file)}
/>
))}
</div>
)}
</div>
</form>
<ChatInput.Form>
<ChatInput.Field />
<ChatInput.Upload onUpload={handleUploadFile} />
<LlamaCloudSelector />
<ChatInput.Submit
disabled={
isLoading || (!input.trim() && files.length === 0 && !imageUrl)
}
/>
</ChatInput.Form>
</ChatInput>
);
}
@@ -0,0 +1,30 @@
import {
ChatMessage,
ContentPosition,
getSourceAnnotationData,
useChatMessage,
} from "@llamaindex/chat-ui";
import { Markdown } from "./custom/markdown";
import { ToolAnnotations } from "./tools/chat-tools";
export function ChatMessageContent() {
const { message } = useChatMessage();
const customContent = [
{
// override the default markdown component
position: ContentPosition.MARKDOWN,
component: (
<Markdown
content={message.content}
sources={getSourceAnnotationData(message.annotations)?.[0]}
/>
),
},
{
// add the tool annotations after events
position: ContentPosition.AFTER_EVENTS,
component: <ToolAnnotations message={message} />,
},
];
return <ChatMessage.Content content={customContent} />;
}
@@ -1,153 +0,0 @@
import { icons, LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "../../button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../../drawer";
import { AgentEventData } from "../index";
import Markdown from "./markdown";
const AgentIcons: Record<string, LucideIcon> = {
bot: icons.Bot,
researcher: icons.ScanSearch,
writer: icons.PenLine,
reviewer: icons.MessageCircle,
publisher: icons.BookCheck,
};
type MergedEvent = {
agent: string;
texts: string[];
icon: LucideIcon;
};
export function ChatAgentEvents({
data,
isFinished,
}: {
data: AgentEventData[];
isFinished: boolean;
}) {
const events = useMemo(() => mergeAdjacentEvents(data), [data]);
return (
<div className="pl-2">
<div className="text-sm space-y-4">
{events.map((eventItem, index) => (
<AgentEventContent
key={index}
event={eventItem}
isLast={index === events.length - 1}
isFinished={isFinished}
/>
))}
</div>
</div>
);
}
const MAX_TEXT_LENGTH = 150;
function AgentEventContent({
event,
isLast,
isFinished,
}: {
event: MergedEvent;
isLast: boolean;
isFinished: boolean;
}) {
const { agent, texts } = event;
const AgentIcon = event.icon;
return (
<div className="flex gap-4 border-b pb-4 items-center fadein-agent">
<div className="w-[100px] flex flex-col items-center gap-2">
<div className="relative">
{isLast && !isFinished && (
<div className="absolute -top-0 -right-4">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
</span>
</div>
)}
<AgentIcon />
</div>
<span className="font-bold">{agent}</span>
</div>
<ul className="flex-1 list-decimal space-y-2">
{texts.map((text, index) => (
<li className="whitespace-break-spaces" key={index}>
{text.length <= MAX_TEXT_LENGTH && <span>{text}</span>}
{text.length > MAX_TEXT_LENGTH && (
<div>
<span>{text.slice(0, MAX_TEXT_LENGTH)}...</span>
<AgentEventDialog
content={text}
title={`Agent "${agent}" - Step: ${index + 1}`}
>
<span className="font-semibold underline cursor-pointer ml-2">
Show more
</span>
</AgentEventDialog>
</div>
)}
</li>
))}
</ul>
</div>
);
}
type AgentEventDialogProps = {
title: string;
content: string;
children: React.ReactNode;
};
function AgentEventDialog(props: AgentEventDialogProps) {
return (
<Drawer direction="left">
<DrawerTrigger asChild>{props.children}</DrawerTrigger>
<DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] ">
<DrawerHeader className="flex justify-between">
<div className="space-y-2">
<DrawerTitle>{props.title}</DrawerTitle>
</div>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerHeader>
<div className="m-4 overflow-auto">
<Markdown content={props.content} />
</div>
</DrawerContent>
</Drawer>
);
}
function mergeAdjacentEvents(events: AgentEventData[]): MergedEvent[] {
const mergedEvents: MergedEvent[] = [];
for (const event of events) {
const lastMergedEvent = mergedEvents[mergedEvents.length - 1];
if (lastMergedEvent && lastMergedEvent.agent === event.agent) {
// If the last event in mergedEvents has the same non-null agent, add the title to it
lastMergedEvent.texts.push(event.text);
} else {
// Otherwise, create a new merged event
mergedEvents.push({
agent: event.agent,
texts: [event.text],
icon: AgentIcons[event.agent] ?? icons.Bot,
});
}
}
return mergedEvents;
}
@@ -1,50 +0,0 @@
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "../../button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../../collapsible";
import { EventData } from "../index";
export function ChatEvents({
data,
isLoading,
}: {
data: EventData[];
isLoading: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const buttonLabel = isOpen ? "Hide events" : "Show events";
const EventIcon = isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
);
return (
<div className="border-l-2 border-indigo-400 pl-2">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button variant="secondary" className="space-x-2">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span>{buttonLabel}</span>
{EventIcon}
</Button>
</CollapsibleTrigger>
<CollapsibleContent asChild>
<div className="mt-4 text-sm space-y-2">
{data.map((eventItem, index) => (
<div className="whitespace-break-spaces" key={index}>
{eventItem.title}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -1,13 +0,0 @@
import { DocumentPreview } from "../../document-preview";
import { DocumentFileData } from "../index";
export function ChatFiles({ data }: { data: DocumentFileData }) {
if (!data.files.length) return null;
return (
<div className="flex gap-2 items-center">
{data.files.map((file, index) => (
<DocumentPreview key={file.id} file={file} />
))}
</div>
);
}
@@ -1,17 +0,0 @@
import Image from "next/image";
import { type ImageData } from "../index";
export function ChatImage({ data }: { data: ImageData }) {
return (
<div className="rounded-md max-w-[200px] shadow-md">
<Image
src={data.url}
width={0}
height={0}
sizes="100vw"
style={{ width: "100%", height: "auto" }}
alt=""
/>
</div>
);
}
@@ -1,173 +0,0 @@
import { Check, Copy } from "lucide-react";
import { useMemo } from "react";
import { Button } from "../../button";
import { PreviewCard } from "../../document-preview";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../../hover-card";
import { cn } from "../../lib/utils";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import { DocumentFileType, SourceData, SourceNode } from "../index";
import PdfDialog from "../widgets/PdfDialog";
type Document = {
url: string;
sources: SourceNode[];
};
export function ChatSources({ data }: { data: SourceData }) {
const documents: Document[] = useMemo(() => {
// group nodes by document (a document must have a URL)
const nodesByUrl: Record<string, SourceNode[]> = {};
data.nodes.forEach((node) => {
const key = node.url;
nodesByUrl[key] ??= [];
nodesByUrl[key].push(node);
});
// convert to array of documents
return Object.entries(nodesByUrl).map(([url, sources]) => ({
url,
sources,
}));
}, [data.nodes]);
if (documents.length === 0) return null;
return (
<div className="space-y-2 text-sm">
<div className="font-semibold text-lg">Sources:</div>
<div className="flex gap-3 flex-wrap">
{documents.map((document) => {
return <DocumentInfo key={document.url} document={document} />;
})}
</div>
</div>
);
}
function SourceInfo({ node, index }: { node?: SourceNode; index: number }) {
if (!node) return <SourceNumberButton index={index} />;
return (
<HoverCard>
<HoverCardTrigger
className="cursor-default"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<SourceNumberButton
index={index}
className="hover:text-white hover:bg-primary"
/>
</HoverCardTrigger>
<HoverCardContent className="w-[400px]">
<NodeInfo nodeInfo={node} />
</HoverCardContent>
</HoverCard>
);
}
export function SourceNumberButton({
index,
className,
}: {
index: number;
className?: string;
}) {
return (
<span
className={cn(
"text-xs w-5 h-5 rounded-full bg-gray-100 inline-flex items-center justify-center",
className,
)}
>
{index + 1}
</span>
);
}
export function DocumentInfo({
document,
className,
}: {
document: Document;
className?: string;
}) {
const { url, sources } = document;
// Extract filename from URL
const urlParts = url.split("/");
const fileName = urlParts.length > 0 ? urlParts[urlParts.length - 1] : url;
const fileExt = fileName?.split(".").pop() as DocumentFileType | undefined;
const previewFile = {
name: fileName,
type: fileExt as DocumentFileType,
};
const DocumentDetail = (
<div className={`relative ${className}`}>
<PreviewCard className={"cursor-pointer"} file={previewFile} />
<div className="absolute bottom-2 right-2 space-x-2 flex">
{sources.map((node: SourceNode, index: number) => (
<div key={node.id}>
<SourceInfo node={node} index={index} />
</div>
))}
</div>
</div>
);
if (url.endsWith(".pdf")) {
// open internal pdf dialog for pdf files when click document card
return <PdfDialog documentId={url} url={url} trigger={DocumentDetail} />;
}
// open external link when click document card for other file types
return <div onClick={() => window.open(url, "_blank")}>{DocumentDetail}</div>;
}
function NodeInfo({ nodeInfo }: { nodeInfo: SourceNode }) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
const pageNumber =
// XXX: page_label is used in Python, but page_number is used by Typescript
(nodeInfo.metadata?.page_number as number) ??
(nodeInfo.metadata?.page_label as number) ??
null;
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="font-semibold">
{pageNumber ? `On page ${pageNumber}:` : "Node content:"}
</span>
{nodeInfo.text && (
<Button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(nodeInfo.text);
}}
size="icon"
variant="ghost"
className="h-12 w-12 shrink-0"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
)}
</div>
{nodeInfo.text && (
<pre className="max-h-[200px] overflow-auto whitespace-pre-line">
&ldquo;{nodeInfo.text}&rdquo;
</pre>
)}
</div>
);
}
@@ -1,31 +0,0 @@
import { ChatHandler, SuggestedQuestionsData } from "..";
export function SuggestedQuestions({
questions,
append,
isLastMessage,
}: {
questions: SuggestedQuestionsData;
append: Pick<ChatHandler, "append">["append"];
isLastMessage: boolean;
}) {
const showQuestions = isLastMessage && questions.length > 0;
return (
showQuestions &&
append !== undefined && (
<div className="flex flex-col space-y-2">
{questions.map((question, index) => (
<a
key={index}
onClick={() => {
append({ role: "user", content: question });
}}
className="text-sm italic hover:underline cursor-pointer"
>
{"->"} {question}
</a>
))}
</div>
)
);
}
@@ -1,40 +0,0 @@
import { ToolData } from "../index";
import { Artifact, CodeArtifact } from "../widgets/Artifact";
import { WeatherCard, WeatherData } from "../widgets/WeatherCard";
// TODO: If needed, add displaying more tool outputs here
export default function ChatTools({
data,
artifactVersion,
}: {
data: ToolData;
artifactVersion?: number;
}) {
if (!data) return null;
const { toolCall, toolOutput } = data;
if (toolOutput.isError) {
return (
<div className="border-l-2 border-red-400 pl-2">
There was an error when calling the tool {toolCall.name} with input:{" "}
<br />
{JSON.stringify(toolCall.input)}
</div>
);
}
switch (toolCall.name) {
case "get_weather_information":
const weatherData = toolOutput.output as unknown as WeatherData;
return <WeatherCard data={weatherData} />;
case "artifact":
return (
<Artifact
artifact={toolOutput.output as CodeArtifact}
version={artifactVersion}
/>
);
default:
return null;
}
}
@@ -1,131 +0,0 @@
"use client";
import hljs from "highlight.js";
// instead of atom-one-dark theme, there are a lot of others: https://highlightjs.org/demo
import "highlight.js/styles/atom-one-dark-reasonable.css";
import { Check, Copy, Download } from "lucide-react";
import { FC, memo, useEffect, useRef } from "react";
import { Button } from "../../button";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
interface Props {
language: string;
value: string;
className?: string;
}
interface languageMap {
[key: string]: string | undefined;
}
export const programmingLanguages: languageMap = {
javascript: ".js",
python: ".py",
java: ".java",
c: ".c",
cpp: ".cpp",
"c++": ".cpp",
"c#": ".cs",
ruby: ".rb",
php: ".php",
swift: ".swift",
"objective-c": ".m",
kotlin: ".kt",
typescript: ".ts",
go: ".go",
perl: ".pl",
rust: ".rs",
scala: ".scala",
haskell: ".hs",
lua: ".lua",
shell: ".sh",
sql: ".sql",
html: ".html",
css: ".css",
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
};
export const generateRandomString = (length: number, lowercase = false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return lowercase ? result.toLowerCase() : result;
};
const CodeBlock: FC<Props> = memo(({ language, value, className }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const codeRef = useRef<HTMLElement>(null);
useEffect(() => {
if (codeRef.current && codeRef.current.dataset.highlighted !== "yes") {
hljs.highlightElement(codeRef.current);
}
}, [language, value]);
const downloadAsFile = () => {
if (typeof window === "undefined") {
return;
}
const fileExtension = programmingLanguages[language] || ".file";
const suggestedFileName = `file-${generateRandomString(
3,
true,
)}${fileExtension}`;
const fileName = window.prompt("Enter file name", suggestedFileName);
if (!fileName) {
// User pressed cancel on prompt.
return;
}
const blob = new Blob([value], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = fileName;
link.href = url;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onCopy = () => {
if (isCopied) return;
copyToClipboard(value);
};
return (
<div
className={`codeblock relative w-full bg-zinc-950 font-sans ${className}`}
>
<div className="flex w-full items-center justify-between bg-zinc-800 px-6 py-2 pr-4 text-zinc-100">
<span className="text-xs lowercase">{language}</span>
<div className="flex items-center space-x-1">
<Button variant="ghost" onClick={downloadAsFile} size="icon">
<Download />
<span className="sr-only">Download</span>
</Button>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="sr-only">Copy code</span>
</Button>
</div>
</div>
<pre className="border border-zinc-700">
<code ref={codeRef} className={`language-${language} font-mono`}>
{value}
</code>
</pre>
</div>
);
});
CodeBlock.displayName = "CodeBlock";
export { CodeBlock };
@@ -1,184 +0,0 @@
import { Check, Copy } from "lucide-react";
import { Message } from "ai";
import { Fragment } from "react";
import { Button } from "../../button";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import {
AgentEventData,
ChatHandler,
DocumentFileData,
EventData,
ImageData,
MessageAnnotation,
MessageAnnotationType,
SuggestedQuestionsData,
ToolData,
getAnnotationData,
getSourceAnnotationData,
} from "../index";
import { ChatAgentEvents } from "./chat-agent-events";
import ChatAvatar from "./chat-avatar";
import { ChatEvents } from "./chat-events";
import { ChatFiles } from "./chat-files";
import { ChatImage } from "./chat-image";
import { ChatSources } from "./chat-sources";
import { SuggestedQuestions } from "./chat-suggestedQuestions";
import ChatTools from "./chat-tools";
import Markdown from "./markdown";
type ContentDisplayConfig = {
order: number;
component: JSX.Element | null;
};
function ChatMessageContent({
message,
isLoading,
append,
isLastMessage,
artifactVersion,
}: {
message: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
isLastMessage: boolean;
artifactVersion: number | undefined;
}) {
const annotations = message.annotations as MessageAnnotation[] | undefined;
if (!annotations?.length) return <Markdown content={message.content} />;
const imageData = getAnnotationData<ImageData>(
annotations,
MessageAnnotationType.IMAGE,
);
const contentFileData = getAnnotationData<DocumentFileData>(
annotations,
MessageAnnotationType.DOCUMENT_FILE,
);
const eventData = getAnnotationData<EventData>(
annotations,
MessageAnnotationType.EVENTS,
);
const agentEventData = getAnnotationData<AgentEventData>(
annotations,
MessageAnnotationType.AGENT_EVENTS,
);
const sourceData = getSourceAnnotationData(annotations);
const toolData = getAnnotationData<ToolData>(
annotations,
MessageAnnotationType.TOOLS,
);
const suggestedQuestionsData = getAnnotationData<SuggestedQuestionsData>(
annotations,
MessageAnnotationType.SUGGESTED_QUESTIONS,
);
const contents: ContentDisplayConfig[] = [
{
order: 1,
component: imageData[0] ? <ChatImage data={imageData[0]} /> : null,
},
{
order: -3,
component:
eventData.length > 0 ? (
<ChatEvents isLoading={isLoading} data={eventData} />
) : null,
},
{
order: -2,
component:
agentEventData.length > 0 ? (
<ChatAgentEvents
data={agentEventData}
isFinished={!!message.content}
/>
) : null,
},
{
order: 2,
component: contentFileData[0] ? (
<ChatFiles data={contentFileData[0]} />
) : null,
},
{
order: -1,
component: toolData[0] ? (
<ChatTools data={toolData[0]} artifactVersion={artifactVersion} />
) : null,
},
{
order: 0,
component: <Markdown content={message.content} sources={sourceData[0]} />,
},
{
order: 3,
component: sourceData[0] ? <ChatSources data={sourceData[0]} /> : null,
},
{
order: 4,
component: suggestedQuestionsData[0] ? (
<SuggestedQuestions
questions={suggestedQuestionsData[0]}
append={append}
isLastMessage={isLastMessage}
/>
) : null,
},
];
return (
<div className="flex-1 gap-4 flex flex-col">
{contents
.sort((a, b) => a.order - b.order)
.map((content, index) => (
<Fragment key={index}>{content.component}</Fragment>
))}
</div>
);
}
export default function ChatMessage({
chatMessage,
isLoading,
append,
isLastMessage,
artifactVersion,
}: {
chatMessage: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
isLastMessage: boolean;
artifactVersion: number | undefined;
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
return (
<div className="flex items-start gap-4 pr-5 pt-5">
<ChatAvatar role={chatMessage.role} />
<div className="group flex flex-1 justify-between gap-2">
<ChatMessageContent
message={chatMessage}
isLoading={isLoading}
append={append}
isLastMessage={isLastMessage}
artifactVersion={artifactVersion}
/>
<Button
onClick={() => copyToClipboard(chatMessage.content)}
size="icon"
variant="ghost"
className="h-8 w-8 opacity-0 group-hover:opacity-100"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}
@@ -1,170 +0,0 @@
import "katex/dist/katex.min.css";
import { FC, memo } from "react";
import ReactMarkdown, { Options } from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { DOCUMENT_FILE_TYPES, DocumentFileType, SourceData } from "..";
import { useClientConfig } from "../hooks/use-config";
import { DocumentInfo, SourceNumberButton } from "./chat-sources";
import { CodeBlock } from "./codeblock";
const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className,
);
const preprocessLaTeX = (content: string) => {
// Replace block-level LaTeX delimiters \[ \] with $$ $$
const blockProcessedContent = content.replace(
/\\\[([\s\S]*?)\\\]/g,
(_, equation) => `$$${equation}$$`,
);
// Replace inline LaTeX delimiters \( \) with $ $
const inlineProcessedContent = blockProcessedContent.replace(
/\\\[([\s\S]*?)\\\]/g,
(_, equation) => `$${equation}$`,
);
return inlineProcessedContent;
};
const preprocessMedia = (content: string) => {
// Remove `sandbox:` from the beginning of the URL
// to fix OpenAI's models issue appending `sandbox:` to the relative URL
return content.replace(/(sandbox|attachment|snt):/g, "");
};
/**
* Update the citation flag [citation:id]() to the new format [citation:index](url)
*/
const preprocessCitations = (content: string, sources?: SourceData) => {
if (sources) {
const citationRegex = /\[citation:(.+?)\]\(\)/g;
let match;
// Find all the citation references in the content
while ((match = citationRegex.exec(content)) !== null) {
const citationId = match[1];
// Find the source node with the id equal to the citation-id, also get the index of the source node
const sourceNode = sources.nodes.find((node) => node.id === citationId);
// If the source node is found, replace the citation reference with the new format
if (sourceNode !== undefined) {
content = content.replace(
match[0],
`[citation:${sources.nodes.indexOf(sourceNode)}]()`,
);
} else {
// If the source node is not found, remove the citation reference
content = content.replace(match[0], "");
}
}
}
return content;
};
const preprocessContent = (content: string, sources?: SourceData) => {
return preprocessCitations(
preprocessMedia(preprocessLaTeX(content)),
sources,
);
};
export default function Markdown({
content,
sources,
}: {
content: string;
sources?: SourceData;
}) {
const processedContent = preprocessContent(content, sources);
const { backend } = useClientConfig();
return (
<MemoizedReactMarkdown
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 break-words custom-markdown"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex as any]}
components={{
p({ children }) {
return <div className="mb-2 last:mb-0">{children}</div>;
},
code({ node, inline, className, children, ...props }) {
if (children.length) {
if (children[0] == "▍") {
return (
<span className="mt-1 animate-pulse cursor-default"></span>
);
}
children[0] = (children[0] as string).replace("`▍`", "▍");
}
const match = /language-(\w+)/.exec(className || "");
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<CodeBlock
key={Math.random()}
language={(match && match[1]) || ""}
value={String(children).replace(/\n$/, "")}
className="mb-2"
{...props}
/>
);
},
a({ href, children }) {
// If href starts with `{backend}/api/files`, then it's a local document and we use DocumenInfo for rendering
if (href?.startsWith(backend + "/api/files")) {
// Check if the file is document file type
const fileExtension = href.split(".").pop()?.toLowerCase();
if (
fileExtension &&
DOCUMENT_FILE_TYPES.includes(fileExtension as DocumentFileType)
) {
return (
<DocumentInfo
document={{
url: new URL(decodeURIComponent(href)).href,
sources: [],
}}
className="mb-2 mt-2"
/>
);
}
}
// If a text link starts with 'citation:', then render it as a citation reference
if (
Array.isArray(children) &&
typeof children[0] === "string" &&
children[0].startsWith("citation:")
) {
const index = Number(children[0].replace("citation:", ""));
if (!isNaN(index)) {
return <SourceNumberButton index={index} />;
} else {
// citation is not looked up yet, don't render anything
return <></>;
}
}
return (
<a href={href} target="_blank">
{children}
</a>
);
},
}}
>
{processedContent}
</MemoizedReactMarkdown>
);
}
@@ -1,136 +1,30 @@
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
"use client";
import { ToolData } from ".";
import { Button } from "../button";
import ChatActions from "./chat-actions";
import ChatMessage from "./chat-message";
import { ChatHandler } from "./chat.interface";
import { useClientConfig } from "./hooks/use-config";
export default function ChatMessages(
props: Pick<
ChatHandler,
"messages" | "isLoading" | "reload" | "stop" | "append"
>,
) {
const { backend } = useClientConfig();
const [starterQuestions, setStarterQuestions] = useState<string[]>();
const scrollableChatContainerRef = useRef<HTMLDivElement>(null);
const messageLength = props.messages.length;
const lastMessage = props.messages[messageLength - 1];
const scrollToBottom = () => {
if (scrollableChatContainerRef.current) {
scrollableChatContainerRef.current.scrollTop =
scrollableChatContainerRef.current.scrollHeight;
}
};
const isLastMessageFromAssistant =
messageLength > 0 && lastMessage?.role !== "user";
const showReload =
props.reload && !props.isLoading && isLastMessageFromAssistant;
const showStop = props.stop && props.isLoading;
// `isPending` indicate
// that stream response is not yet received from the server,
// so we show a loading indicator to give a better UX.
const isPending = props.isLoading && !isLastMessageFromAssistant;
useEffect(() => {
scrollToBottom();
}, [messageLength, lastMessage]);
useEffect(() => {
if (!starterQuestions) {
fetch(`${backend}/api/chat/config`)
.then((response) => response.json())
.then((data) => {
if (data?.starterQuestions) {
setStarterQuestions(data.starterQuestions);
}
})
.catch((error) => console.error("Error fetching config", error));
}
}, [starterQuestions, backend]);
// build a map of message id to artifact version
const artifactVersionMap = useMemo(() => {
const map = new Map<string, number | undefined>();
let versionIndex = 1;
props.messages.forEach((m) => {
m.annotations?.forEach((annotation) => {
if (
typeof annotation === "object" &&
annotation != null &&
"type" in annotation &&
annotation.type === "tools"
) {
const data = annotation.data as ToolData;
if (data?.toolCall?.name === "artifact") {
map.set(m.id, versionIndex);
versionIndex++;
}
}
});
});
return map;
}, [props.messages]);
import { ChatMessage, ChatMessages, useChatUI } from "@llamaindex/chat-ui";
import { ChatMessageAvatar } from "./chat-avatar";
import { ChatMessageContent } from "./chat-message-content";
import { ChatStarter } from "./chat-starter";
export default function CustomChatMessages() {
const { messages } = useChatUI();
return (
<div
className="flex-1 w-full rounded-xl bg-white p-4 shadow-xl relative overflow-y-auto"
ref={scrollableChatContainerRef}
>
<div className="flex flex-col gap-5 divide-y">
{props.messages.map((m, i) => {
const isLoadingMessage = i === messageLength - 1 && props.isLoading;
return (
<ChatMessage
key={m.id}
chatMessage={m}
isLoading={isLoadingMessage}
append={props.append!}
isLastMessage={i === messageLength - 1}
artifactVersion={artifactVersionMap.get(m.id)}
/>
);
})}
{isPending && (
<div className="flex justify-center items-center pt-10">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
{(showReload || showStop) && (
<div className="flex justify-end py-4">
<ChatActions
reload={props.reload}
stop={props.stop}
showReload={showReload}
showStop={showStop}
/>
</div>
)}
{!messageLength && starterQuestions?.length && props.append && (
<div className="absolute bottom-6 left-0 w-full">
<div className="grid grid-cols-2 gap-2 mx-20">
{starterQuestions.map((question, i) => (
<Button
variant="outline"
key={i}
onClick={() =>
props.append!({ role: "user", content: question })
}
>
{question}
</Button>
))}
</div>
</div>
)}
</div>
<ChatMessages className="shadow-xl rounded-xl">
<ChatMessages.List>
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
isLast={index === messages.length - 1}
>
<ChatMessageAvatar />
<ChatMessageContent />
<ChatMessage.Actions />
</ChatMessage>
))}
<ChatMessages.Loading />
</ChatMessages.List>
<ChatMessages.Actions />
<ChatStarter />
</ChatMessages>
);
}
@@ -0,0 +1,26 @@
import { useChatUI } from "@llamaindex/chat-ui";
import { StarterQuestions } from "@llamaindex/chat-ui/widgets";
import { useEffect, useState } from "react";
import { useClientConfig } from "./hooks/use-config";
export function ChatStarter() {
const { append } = useChatUI();
const { backend } = useClientConfig();
const [starterQuestions, setStarterQuestions] = useState<string[]>();
useEffect(() => {
if (!starterQuestions) {
fetch(`${backend}/api/chat/config`)
.then((response) => response.json())
.then((data) => {
if (data?.starterQuestions) {
setStarterQuestions(data.starterQuestions);
}
})
.catch((error) => console.error("Error fetching config", error));
}
}, [starterQuestions, backend]);
if (!starterQuestions?.length) return null;
return <StarterQuestions append={append} questions={starterQuestions} />;
}
@@ -1,25 +0,0 @@
import { Message } from "ai";
export interface ChatHandler {
messages: Message[];
input: string;
isLoading: boolean;
handleSubmit: (
e: React.FormEvent<HTMLFormElement>,
ops?: {
data?: any;
},
) => void;
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
reload?: () => void;
stop?: () => void;
onFileUpload?: (file: File) => Promise<void>;
onFileError?: (errMsg: string) => void;
setInput?: (input: string) => void;
append?: (
message: Message | Omit<Message, "id">,
ops?: {
data: any;
},
) => Promise<string | null | undefined>;
}
@@ -1,3 +1,4 @@
import { useChatUI } from "@llamaindex/chat-ui";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
@@ -35,19 +36,18 @@ type LlamaCloudConfig = {
};
export interface LlamaCloudSelectorProps {
setRequestData?: React.Dispatch<any>;
onSelect?: (pipeline: PipelineConfig | undefined) => void;
defaultPipeline?: PipelineConfig;
shouldCheckValid?: boolean;
}
export function LlamaCloudSelector({
setRequestData,
onSelect,
defaultPipeline,
shouldCheckValid = false,
}: LlamaCloudSelectorProps) {
const { backend } = useClientConfig();
const { setRequestData } = useChatUI();
const [config, setConfig] = useState<LlamaCloudConfig>();
const updateRequestParams = useCallback(
@@ -97,6 +97,10 @@ export function LlamaCloudSelector({
setPipeline(JSON.parse(value) as PipelineConfig);
};
if (process.env.NEXT_PUBLIC_USE_LLAMACLOUD !== "true") {
return null;
}
if (!config) {
return (
<div className="flex justify-center items-center p-3">
@@ -0,0 +1,27 @@
import { SourceData } from "@llamaindex/chat-ui";
import { Markdown as MarkdownUI } from "@llamaindex/chat-ui/widgets";
import { useClientConfig } from "../hooks/use-config";
const preprocessMedia = (content: string) => {
// Remove `sandbox:` from the beginning of the URL before rendering markdown
// OpenAI models sometimes prepend `sandbox:` to relative URLs - this fixes it
return content.replace(/(sandbox|attachment|snt):/g, "");
};
export function Markdown({
content,
sources,
}: {
content: string;
sources?: SourceData;
}) {
const { backend } = useClientConfig();
const processedContent = preprocessMedia(content);
return (
<MarkdownUI
content={processedContent}
backend={backend}
sources={sources}
/>
);
}
@@ -1,121 +0,0 @@
"use client";
import { JSONValue } from "llamaindex";
import { useState } from "react";
import {
DocumentFile,
DocumentFileType,
MessageAnnotation,
MessageAnnotationType,
} from "..";
import { useClientConfig } from "./use-config";
const docMineTypeMap: Record<string, DocumentFileType> = {
"text/csv": "csv",
"application/pdf": "pdf",
"text/plain": "txt",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
};
export function useFile() {
const { backend } = useClientConfig();
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [files, setFiles] = useState<DocumentFile[]>([]);
const addDoc = (file: DocumentFile) => {
const existedFile = files.find((f) => f.id === file.id);
if (!existedFile) {
setFiles((prev) => [...prev, file]);
return true;
}
return false;
};
const removeDoc = (file: DocumentFile) => {
setFiles((prev) => prev.filter((f) => f.id !== file.id));
};
const reset = () => {
imageUrl && setImageUrl(null);
files.length && setFiles([]);
};
const uploadContent = async (
file: File,
requestParams: any = {},
): Promise<DocumentFile> => {
const base64 = await readContent({ file, asUrl: true });
const uploadAPI = `${backend}/api/chat/upload`;
const response = await fetch(uploadAPI, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...requestParams,
base64,
name: file.name,
}),
});
if (!response.ok) throw new Error("Failed to upload document.");
return (await response.json()) as DocumentFile;
};
const getAnnotations = () => {
const annotations: MessageAnnotation[] = [];
if (imageUrl) {
annotations.push({
type: MessageAnnotationType.IMAGE,
data: { url: imageUrl },
});
}
if (files.length > 0) {
annotations.push({
type: MessageAnnotationType.DOCUMENT_FILE,
data: { files },
});
}
return annotations as JSONValue[];
};
const readContent = async (input: {
file: File;
asUrl?: boolean;
}): Promise<string> => {
const { file, asUrl } = input;
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
if (asUrl) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
return content;
};
const uploadFile = async (file: File, requestParams: any = {}) => {
if (file.type.startsWith("image/")) {
const base64 = await readContent({ file, asUrl: true });
return setImageUrl(base64);
}
const filetype = docMineTypeMap[file.type];
if (!filetype) throw new Error("Unsupported document type.");
const newDoc = await uploadContent(file, requestParams);
return addDoc(newDoc);
};
return {
imageUrl,
setImageUrl,
files,
removeDoc,
reset,
getAnnotations,
uploadFile,
};
}
@@ -1,131 +0,0 @@
import { JSONValue } from "ai";
import ChatInput from "./chat-input";
import ChatMessages from "./chat-messages";
export { type ChatHandler } from "./chat.interface";
export { ChatInput, ChatMessages };
export enum MessageAnnotationType {
IMAGE = "image",
DOCUMENT_FILE = "document_file",
SOURCES = "sources",
EVENTS = "events",
TOOLS = "tools",
SUGGESTED_QUESTIONS = "suggested_questions",
AGENT_EVENTS = "agent",
}
export type ImageData = {
url: string;
};
export type DocumentFileType = "csv" | "pdf" | "txt" | "docx";
export const DOCUMENT_FILE_TYPES: DocumentFileType[] = [
"csv",
"pdf",
"txt",
"docx",
];
export type DocumentFile = {
id: string;
name: string; // The uploaded file name in the backend
size: number; // The file size in bytes
type: DocumentFileType;
url: string; // The URL of the uploaded file in the backend
refs?: string[]; // DocumentIDs of the uploaded file in the vector index
};
export type DocumentFileData = {
files: DocumentFile[];
};
export type SourceNode = {
id: string;
metadata: Record<string, unknown>;
score?: number;
text: string;
url: string;
};
export type SourceData = {
nodes: SourceNode[];
};
export type EventData = {
title: string;
};
export type AgentEventData = {
agent: string;
text: string;
};
export type ToolData = {
toolCall: {
id: string;
name: string;
input: {
[key: string]: JSONValue;
};
};
toolOutput: {
output: JSONValue;
isError: boolean;
};
};
export type SuggestedQuestionsData = string[];
export type AnnotationData =
| ImageData
| DocumentFileData
| SourceData
| EventData
| AgentEventData
| ToolData
| SuggestedQuestionsData;
export type MessageAnnotation = {
type: MessageAnnotationType;
data: AnnotationData;
};
const NODE_SCORE_THRESHOLD = 0.25;
export function getAnnotationData<T extends AnnotationData>(
annotations: MessageAnnotation[],
type: MessageAnnotationType,
): T[] {
return annotations.filter((a) => a.type === type).map((a) => a.data as T);
}
export function getSourceAnnotationData(
annotations: MessageAnnotation[],
): SourceData[] {
const data = getAnnotationData<SourceData>(
annotations,
MessageAnnotationType.SOURCES,
);
if (data.length > 0) {
const sourceData = data[0] as SourceData;
if (sourceData.nodes) {
sourceData.nodes = preprocessSourceNodes(sourceData.nodes);
}
}
return data;
}
function preprocessSourceNodes(nodes: SourceNode[]): SourceNode[] {
// Filter source nodes has lower score
nodes = nodes
.filter((node) => (node.score ?? 1) > NODE_SCORE_THRESHOLD)
.filter((node) => node.url && node.url.trim() !== "")
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1))
.map((node) => {
// remove trailing slash for node url if exists
node.url = node.url.replace(/\/$/, "");
return node;
});
return nodes;
}
@@ -10,7 +10,7 @@ import {
} from "../../collapsible";
import { cn } from "../../lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tabs";
import Markdown from "../chat-message/markdown";
import { Markdown } from "../custom/markdown";
import { useClientConfig } from "../hooks/use-config";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
@@ -29,12 +29,17 @@ export type CodeArtifact = {
files?: string[];
};
type OutputUrl = {
url: string;
filename: string;
};
type ArtifactResult = {
template: string;
stdout: string[];
stderr: string[];
runtimeError?: { name: string; value: string; tracebackRaw: string[] };
outputUrls: Array<{ url: string; filename: string }>;
outputUrls: OutputUrl[];
url: string;
};
@@ -272,11 +277,7 @@ function CodeSandboxPreview({ url }: { url: string }) {
);
}
function InterpreterOutput({
outputUrls,
}: {
outputUrls: Array<{ url: string; filename: string }>;
}) {
function InterpreterOutput({ outputUrls }: { outputUrls: OutputUrl[] }) {
return (
<ul className="flex flex-col gap-2 mt-4">
{outputUrls.map((url) => (
@@ -0,0 +1,89 @@
import {
getAnnotationData,
MessageAnnotation,
useChatMessage,
useChatUI,
} from "@llamaindex/chat-ui";
import { JSONValue, Message } from "ai";
import { useMemo } from "react";
import { Artifact, CodeArtifact } from "./artifact";
import { WeatherCard, WeatherData } from "./weather-card";
export function ToolAnnotations({ message }: { message: Message }) {
const annotations = message.annotations as MessageAnnotation[] | undefined;
const toolData = annotations
? (getAnnotationData(annotations, "tools") as unknown as ToolData[])
: null;
return toolData?.[0] ? <ChatTools data={toolData[0]} /> : null;
}
// TODO: Used to render outputs of tools. If needed, add more renderers here.
function ChatTools({ data }: { data: ToolData }) {
const { messages } = useChatUI();
const { message } = useChatMessage();
// build a map of message id to artifact version
const artifactVersionMap = useMemo(() => {
const map = new Map<string, number | undefined>();
let versionIndex = 1;
messages.forEach((m) => {
m.annotations?.forEach((annotation: any) => {
if (
typeof annotation === "object" &&
annotation != null &&
"type" in annotation &&
annotation.type === "tools"
) {
const data = annotation.data as ToolData;
if (data?.toolCall?.name === "artifact") {
map.set(m.id, versionIndex);
versionIndex++;
}
}
});
});
return map;
}, [messages]);
if (!data) return null;
const { toolCall, toolOutput } = data;
if (toolOutput.isError) {
return (
<div className="border-l-2 border-red-400 pl-2">
There was an error when calling the tool {toolCall.name} with input:{" "}
<br />
{JSON.stringify(toolCall.input)}
</div>
);
}
switch (toolCall.name) {
case "get_weather_information":
const weatherData = toolOutput.output as unknown as WeatherData;
return <WeatherCard data={weatherData} />;
case "artifact":
return (
<Artifact
artifact={toolOutput.output as CodeArtifact}
version={artifactVersionMap.get(message.id)}
/>
);
default:
return null;
}
}
type ToolData = {
toolCall: {
id: string;
name: string;
input: {
[key: string]: JSONValue;
};
};
toolOutput: {
output: JSONValue;
isError: boolean;
};
};
@@ -1,67 +0,0 @@
import dynamic from "next/dynamic";
import { Button } from "../../button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../../drawer";
export interface PdfDialogProps {
documentId: string;
url: string;
trigger: React.ReactNode;
}
// Dynamic imports for client-side rendering only
const PDFViewer = dynamic(
() => import("@llamaindex/pdf-viewer").then((module) => module.PDFViewer),
{ ssr: false },
);
const PdfFocusProvider = dynamic(
() =>
import("@llamaindex/pdf-viewer").then((module) => module.PdfFocusProvider),
{ ssr: false },
);
export default function PdfDialog(props: PdfDialogProps) {
return (
<Drawer direction="left">
<DrawerTrigger asChild>{props.trigger}</DrawerTrigger>
<DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] ">
<DrawerHeader className="flex justify-between">
<div className="space-y-2">
<DrawerTitle>PDF Content</DrawerTitle>
<DrawerDescription>
File URL:{" "}
<a
className="hover:text-blue-900"
href={props.url}
target="_blank"
>
{props.url}
</a>
</DrawerDescription>
</div>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerHeader>
<div className="m-4">
<PdfFocusProvider>
<PDFViewer
file={{
id: props.documentId,
url: props.url,
}}
/>
</PdfFocusProvider>
</div>
</DrawerContent>
</Drawer>
);
}
@@ -1,129 +0,0 @@
import { XCircleIcon } from "lucide-react";
import Image from "next/image";
import DocxIcon from "../ui/icons/docx.svg";
import PdfIcon from "../ui/icons/pdf.svg";
import SheetIcon from "../ui/icons/sheet.svg";
import TxtIcon from "../ui/icons/txt.svg";
import { Button } from "./button";
import { DocumentFile, DocumentFileType } from "./chat";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./drawer";
import { cn } from "./lib/utils";
export interface DocumentPreviewProps {
file: DocumentFile;
onRemove?: () => void;
}
export function DocumentPreview(props: DocumentPreviewProps) {
const { name, size, type, refs } = props.file;
if (refs?.length) {
return (
<div title={`Document IDs: ${refs.join(", ")}`}>
<PreviewCard {...props} />
</div>
);
}
return (
<Drawer direction="left">
<DrawerTrigger asChild>
<div>
<PreviewCard className="cursor-pointer" {...props} />
</div>
</DrawerTrigger>
<DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] ">
<DrawerHeader className="flex justify-between">
<div className="space-y-2">
<DrawerTitle>{type.toUpperCase()} Raw Content</DrawerTitle>
<DrawerDescription>
{name} ({inKB(size)} KB)
</DrawerDescription>
</div>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerHeader>
<div className="m-4 max-h-[80%] overflow-auto">
{refs?.length && (
<pre className="bg-secondary rounded-md p-4 block text-sm">
{refs.join(", ")}
</pre>
)}
</div>
</DrawerContent>
</Drawer>
);
}
export const FileIcon: Record<DocumentFileType, string> = {
csv: SheetIcon,
pdf: PdfIcon,
docx: DocxIcon,
txt: TxtIcon,
};
export function PreviewCard(props: {
file: {
name: string;
size?: number;
type: DocumentFileType;
};
onRemove?: () => void;
className?: string;
}) {
const { onRemove, file, className } = props;
return (
<div
className={cn(
"p-2 w-60 max-w-60 bg-secondary rounded-lg text-sm relative",
className,
)}
>
<div className="flex flex-row items-center gap-2">
<div className="relative h-8 w-8 shrink-0 overflow-hidden rounded-md flex items-center justify-center">
<Image
className="h-full w-auto object-contain"
priority
src={FileIcon[file.type]}
alt="Icon"
/>
</div>
<div className="overflow-hidden">
<div className="truncate font-semibold">
{file.name} {file.size ? `(${inKB(file.size)} KB)` : ""}
</div>
{file.type && (
<div className="truncate text-token-text-tertiary flex items-center gap-2">
<span>{file.type.toUpperCase()} File</span>
</div>
)}
</div>
</div>
{onRemove && (
<div
className={cn(
"absolute -top-2 -right-2 w-6 h-6 z-10 bg-gray-500 text-white rounded-full",
)}
>
<XCircleIcon
className="w-6 h-6 bg-gray-500 text-white rounded-full"
onClick={onRemove}
/>
</div>
)}
</div>
);
}
function inKB(size: number) {
return Math.round((size / 1024) * 10) / 10;
}
@@ -1,105 +0,0 @@
"use client";
import { Loader2, Paperclip } from "lucide-react";
import { ChangeEvent, useState } from "react";
import { buttonVariants } from "./button";
import { cn } from "./lib/utils";
export interface FileUploaderProps {
config?: {
inputId?: string;
fileSizeLimit?: number;
allowedExtensions?: string[];
checkExtension?: (extension: string) => string | null;
disabled: boolean;
};
onFileUpload: (file: File) => Promise<void>;
onFileError?: (errMsg: string) => void;
}
const DEFAULT_INPUT_ID = "fileInput";
const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50; // 50 MB
export default function FileUploader({
config,
onFileUpload,
onFileError,
}: FileUploaderProps) {
const [uploading, setUploading] = useState(false);
const inputId = config?.inputId || DEFAULT_INPUT_ID;
const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT;
const allowedExtensions = config?.allowedExtensions;
const defaultCheckExtension = (extension: string) => {
if (allowedExtensions && !allowedExtensions.includes(extension)) {
return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions!.join(
",",
)}`;
}
return null;
};
const checkExtension = config?.checkExtension ?? defaultCheckExtension;
const isFileSizeExceeded = (file: File) => {
return file.size > fileSizeLimit;
};
const resetInput = () => {
const fileInput = document.getElementById(inputId) as HTMLInputElement;
fileInput.value = "";
};
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
await handleUpload(file);
resetInput();
setUploading(false);
};
const handleUpload = async (file: File) => {
const onFileUploadError = onFileError || window.alert;
const fileExtension = file.name.split(".").pop() || "";
const extensionFileError = checkExtension(fileExtension);
if (extensionFileError) {
return onFileUploadError(extensionFileError);
}
if (isFileSizeExceeded(file)) {
return onFileUploadError(
`File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`,
);
}
await onFileUpload(file);
};
return (
<div className="self-stretch">
<input
type="file"
id={inputId}
style={{ display: "none" }}
onChange={onFileChange}
accept={allowedExtensions?.join(",")}
disabled={config?.disabled || uploading}
/>
<label
htmlFor={inputId}
className={cn(
buttonVariants({ variant: "secondary", size: "icon" }),
"cursor-pointer",
uploading && "opacity-50",
)}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="-rotate-45 w-4 h-4" />
)}
</label>
</div>
);
}
@@ -0,0 +1,27 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "./lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -1,32 +0,0 @@
import { XCircleIcon } from "lucide-react";
import Image from "next/image";
import { cn } from "./lib/utils";
export default function UploadImagePreview({
url,
onRemove,
}: {
url: string;
onRemove: () => void;
}) {
return (
<div className="relative w-20 h-20 group">
<Image
src={url}
alt="Uploaded image"
fill
className="object-cover w-full h-full rounded-xl hover:brightness-75"
/>
<div
className={cn(
"absolute -top-2 -right-2 w-6 h-6 z-10 bg-gray-500 text-white rounded-full hidden group-hover:block",
)}
>
<XCircleIcon
className="w-6 h-6 bg-gray-500 text-white rounded-full"
onClick={onRemove}
/>
</div>
</div>
);
}
@@ -1 +1,2 @@
// TODO: You can add observability here. For templates re-start `create-llama` with `--pro` flag to generate a new project with observability.
export const initObservability = () => {};
+3 -10
View File
@@ -12,10 +12,9 @@
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@e2b/code-interpreter": "0.0.9-beta.3",
"@llamaindex/core": "^0.2.6",
"@llamaindex/pdf-viewer": "^1.1.3",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.0",
@@ -27,24 +26,18 @@
"duck-duck-scrape": "^2.2.5",
"formdata-node": "^6.0.3",
"got": "^14.4.1",
"llamaindex": "0.6.22",
"llamaindex": "0.8.2",
"lucide-react": "^0.294.0",
"next": "^14.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"rehype-katex": "^7.0.0",
"remark": "^14.0.3",
"remark-code-import": "^1.2.0",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"supports-color": "^8.1.1",
"tailwind-merge": "^2.1.0",
"tiktoken": "^1.0.15",
"uuid": "^9.0.1",
"vaul": "^0.9.1",
"marked": "^14.1.2",
"highlight.js": "^11.10.0"
"@llamaindex/chat-ui": "0.0.4"
},
"devDependencies": {
"@types/node": "^20.10.3",
@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
content: [
"app/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,