Compare commits

...

22 Commits

Author SHA1 Message Date
Thuc Pham 0a2c48fd50 rename type and make it scrollable 2024-06-01 09:15:01 +07:00
Thuc Pham 508eebe39c feat: support multiple csv upload and reuse 2024-06-01 09:15:01 +07:00
Thuc Pham f9a4f7644a hide uploaded csv files if choose a new one 2024-06-01 09:15:01 +07:00
Thuc Pham 34f95bfa29 remove all multiModal props 2024-06-01 09:15:01 +07:00
Huu Le 1148ddba53 bump llama-index-agent-openai version to 0.2.6 (#107) 2024-05-31 13:46:35 +01:00
Huu Le 9e945ed355 bump llama_index and gemini version (#106) 2024-05-31 15:12:14 +07:00
Thuc Pham 6342163df2 Merge pull request #103 from run-llama/feat/add-openapi-tool
feat: Add OpenAPI Action tool
2024-05-30 15:33:36 +07:00
Thuc Pham a42fa53a6b feat: implement csv upload (#96)
* feat: implement interpreter tool

* build tool system prompt

* refactor: use local file system, use absolute resource url

* fix: typo

* feat: implement csv upload

* remove dead code

* fix lint

* update icon & fix code review

* fix lint

* Update .gitignore

* Update pre-commit

* add timeout for streaming

* Create bright-turkeys-melt.md

* remove multi modal prop

* suggest csv resources from frontend annotation data

* get resouces inside chat input

* resolve conflict

* update convert message content

* fix lint

* feat: limit display

---------

Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-05-30 10:38:54 +07:00
leehuwuj 099f626586 use urlparse for file path 2024-05-30 10:05:00 +07:00
leehuwuj 956538eeb0 add changeset 2024-05-30 09:27:21 +07:00
leehuwuj 555f6b2905 refactor code 2024-05-30 09:25:56 +07:00
leehuwuj d8bc271a21 add local tool that combine openapi and request tool 2024-05-30 09:11:21 +07:00
leehuwuj f29561cde2 add cache to toolfactory load_tools 2024-05-29 10:40:40 +07:00
leehuwuj 442abae8ac add openapi tool and http request tool 2024-05-29 08:40:16 +07:00
Huu Le 0ad2207684 Merge pull request #98 from run-llama/feat/construct-resource-url-from-backend
feat: construct resource url from backend
2024-05-28 20:43:04 +07:00
Thuc Pham bfde30deed move logger to global scope 2024-05-28 18:42:46 +07:00
Thuc Pham 96fdb83abf use logger warning 2024-05-28 18:33:53 +07:00
Huu Le b7e0072c9c chore: always generate tools config if user selects agent mode (#102) 2024-05-28 14:35:36 +07:00
Thuc Pham 81bc340dda add warning when no file server url prefix 2024-05-27 18:21:32 +07:00
Thuc Pham ddf3aef7dc remove node path 2024-05-27 18:20:27 +07:00
Thuc Pham 1f5a26f3a8 Merge pull request #100 from run-llama/feat/code-interpreter-python
feat: add support for FastAPI in code interpreter tool
2024-05-27 16:58:32 +07:00
Thuc Pham 48188ca3f9 feat: construct resource url from backend 2024-05-24 14:40:44 +07:00
30 changed files with 807 additions and 163 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"create-llama": patch
---
Add CSV upload
+5
View File
@@ -0,0 +1,5 @@
---
"create-llama": patch
---
Add OpenAPI action tool
+6
View File
@@ -276,6 +276,12 @@ const getEngineEnvs = (): EnvVar[] => {
"The number of similar embeddings to return when retrieving documents.",
value: "3",
},
{
name: "STREAM_TIMEOUT",
description:
"The time in milliseconds to wait for the stream to return a response.",
value: "60000",
},
];
};
+2 -2
View File
@@ -144,7 +144,7 @@ const getAdditionalDependencies = (
case "openai":
dependencies.push({
name: "llama-index-agent-openai",
version: "0.2.2",
version: "0.2.6",
});
break;
case "anthropic":
@@ -160,7 +160,7 @@ const getAdditionalDependencies = (
case "gemini":
dependencies.push({
name: "llama-index-llms-gemini",
version: "0.1.7",
version: "0.1.10",
});
dependencies.push({
name: "llama-index-embeddings-gemini",
+31 -1
View File
@@ -117,6 +117,37 @@ export const supportedTools: Tool[] = [
},
],
},
{
display: "OpenAPI action",
name: "openapi_action.OpenAPIActionToolSpec",
dependencies: [
{
name: "llama-index-tools-openapi",
version: "0.1.3",
},
{
name: "jsonschema",
version: "^4.22.0",
},
{
name: "llama-index-tools-requests",
version: "0.1.3",
},
],
config: {
openapi_uri: "The URL or file path of the OpenAPI schema",
},
supportedFrameworks: ["fastapi"],
type: ToolType.LOCAL,
envVars: [
{
name: TOOL_SYSTEM_PROMPT_ENV_VAR,
description: "System prompt for openapi action tool.",
value:
"You are an OpenAPI action agent. You help users to make requests to the provided OpenAPI schema.",
},
],
},
];
export const getTool = (toolName: string): Tool | undefined => {
@@ -159,7 +190,6 @@ export const writeToolsConfig = async (
tools: Tool[] = [],
type: ConfigFileType = ConfigFileType.YAML,
) => {
if (tools.length === 0) return; // no tools selected, no config need
const configContent: {
[key in ToolType]: Record<string, any>;
} = {
@@ -1,7 +1,8 @@
import os
import yaml
import json
import importlib
from cachetools import cached, LRUCache
from llama_index.core.tools.tool_spec.base import BaseToolSpec
from llama_index.core.tools.function_tool import FunctionTool
@@ -19,6 +20,14 @@ class ToolFactory:
}
@staticmethod
@cached(
LRUCache(maxsize=100),
key=lambda tool_type, tool_name, config: (
tool_type,
tool_name,
json.dumps(config, sort_keys=True),
),
)
def load_tools(tool_type: str, tool_name: str, config: dict) -> list[FunctionTool]:
source_package = ToolFactory.TOOL_SOURCE_PACKAGE_MAP[tool_type]
try:
@@ -0,0 +1,71 @@
from typing import Dict, List, Tuple
from llama_index.tools.openapi import OpenAPIToolSpec
from llama_index.tools.requests import RequestsToolSpec
class OpenAPIActionToolSpec(OpenAPIToolSpec, RequestsToolSpec):
"""
A combination of OpenAPI and Requests tool specs that can parse OpenAPI specs and make requests.
openapi_uri: str: The file path or URL to the OpenAPI spec.
domain_headers: dict: Whitelist domains and the headers to use.
"""
spec_functions = OpenAPIToolSpec.spec_functions + RequestsToolSpec.spec_functions
def __init__(self, openapi_uri: str, domain_headers: dict = {}, **kwargs):
# Load the OpenAPI spec
openapi_spec, servers = self.load_openapi_spec(openapi_uri)
# Add the servers to the domain headers if they are not already present
for server in servers:
if server not in domain_headers:
domain_headers[server] = {}
OpenAPIToolSpec.__init__(self, spec=openapi_spec)
RequestsToolSpec.__init__(self, domain_headers)
@staticmethod
def load_openapi_spec(uri: str) -> Tuple[Dict, List[str]]:
"""
Load an OpenAPI spec from a URI.
Args:
uri (str): A file path or URL to the OpenAPI spec.
Returns:
List[Document]: A list of Document objects.
"""
import yaml
from urllib.parse import urlparse
if uri.startswith("http"):
import requests
response = requests.get(uri)
if response.status_code != 200:
raise ValueError(
"Could not initialize OpenAPIActionToolSpec: "
f"Failed to load OpenAPI spec from {uri}, status code: {response.status_code}"
)
spec = yaml.safe_load(response.text)
elif uri.startswith("file"):
filepath = urlparse(uri).path
with open(filepath, "r") as file:
spec = yaml.safe_load(file)
else:
raise ValueError(
"Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI URI provided. "
"Only HTTP and file path are supported."
)
# Add the servers to the whitelist
try:
servers = [
urlparse(server["url"]).netloc for server in spec.get("servers", [])
]
except KeyError as e:
raise ValueError(
"Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI spec provided. "
"Could not get `servers` from the spec."
) from e
return spec, servers
@@ -15,7 +15,7 @@ export type InterpreterToolParams = {
fileServerURLPrefix?: string;
};
export type InterpreterToolOuput = {
export type InterpreterToolOutput = {
isError: boolean;
logs: Logs;
extraResult: InterpreterExtraResult[];
@@ -88,7 +88,7 @@ export class InterpreterTool implements BaseTool<InterpreterParameter> {
return this.codeInterpreter;
}
public async codeInterpret(code: string): Promise<InterpreterToolOuput> {
public async codeInterpret(code: string): Promise<InterpreterToolOutput> {
console.log(
`\n${"=".repeat(50)}\n> Running following AI-generated code:\n${code}\n${"=".repeat(50)}`,
);
@@ -96,7 +96,7 @@ export class InterpreterTool implements BaseTool<InterpreterParameter> {
const exec = await interpreter.notebook.execCell(code);
if (exec.error) console.error("[Code Interpreter error]", exec.error);
const extraResult = await this.getExtraResult(exec.results[0]);
const result: InterpreterToolOuput = {
const result: InterpreterToolOutput = {
isError: !!exec.error,
logs: exec.logs,
extraResult,
@@ -104,7 +104,7 @@ export class InterpreterTool implements BaseTool<InterpreterParameter> {
return result;
}
async call(input: InterpreterParameter): Promise<InterpreterToolOuput> {
async call(input: InterpreterParameter): Promise<InterpreterToolOutput> {
const result = await this.codeInterpret(input.code);
await this.codeInterpreter?.close();
return result;
@@ -1,5 +1,7 @@
"use client";
import { Message } from "./chat-messages";
export interface ChatInputProps {
/** The current value of the input */
input?: string;
@@ -12,7 +14,7 @@ export interface ChatInputProps {
/** Form submission handler to automatically reset input and append a user message */
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
multiModal?: boolean;
messages: Message[];
}
export default function ChatInput(props: ChatInputProps) {
@@ -1,32 +1,23 @@
import { Message, StreamData, streamToResponse } from "ai";
import { Request, Response } from "express";
import { ChatMessage, MessageContent, Settings } from "llamaindex";
import { ChatMessage, Settings } from "llamaindex";
import { createChatEngine } from "./engine/chat";
import { LlamaIndexStream } from "./llamaindex-stream";
import { createCallbackManager } from "./stream-helper";
const convertMessageContent = (
textMessage: string,
imageUrl: string | undefined,
): MessageContent => {
if (!imageUrl) return textMessage;
return [
{
type: "text",
text: textMessage,
},
{
type: "image_url",
image_url: {
url: imageUrl,
},
},
];
};
import {
DataParserOptions,
LlamaIndexStream,
convertMessageContent,
} from "./llamaindex-stream";
import { createCallbackManager, createStreamTimeout } from "./stream-helper";
export const chat = async (req: Request, res: Response) => {
// Init Vercel AI StreamData and timeout
const vercelStreamData = new StreamData();
const streamTimeout = createStreamTimeout(vercelStreamData);
try {
const { messages, data }: { messages: Message[]; data: any } = req.body;
const {
messages,
data,
}: { messages: Message[]; data: DataParserOptions | undefined } = req.body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
return res.status(400).json({
@@ -38,13 +29,7 @@ export const chat = async (req: Request, res: Response) => {
const chatEngine = await createChatEngine();
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
data?.imageUrl,
);
// Init Vercel AI StreamData
const vercelStreamData = new StreamData();
const userMessageContent = convertMessageContent(userMessage.content, data);
// Setup callbacks
const callbackManager = createCallbackManager(vercelStreamData);
@@ -61,7 +46,8 @@ export const chat = async (req: Request, res: Response) => {
// Return a stream, which can be consumed by the Vercel/AI client
const stream = LlamaIndexStream(response, vercelStreamData, {
parserOptions: {
image_url: data?.imageUrl,
imageUrl: data?.imageUrl,
csvFiles: data?.csvFiles,
},
});
@@ -71,5 +57,7 @@ export const chat = async (req: Request, res: Response) => {
return res.status(500).json({
detail: (error as Error).message,
});
} finally {
clearTimeout(streamTimeout);
}
};
@@ -6,6 +6,7 @@ import {
type AIStreamCallbacksAndOptions,
} from "ai";
import {
MessageContent,
Metadata,
NodeWithScore,
Response,
@@ -13,20 +14,61 @@ import {
} from "llamaindex";
import { AgentStreamChatResponse } from "llamaindex/agent/base";
import { appendImageData, appendSourceData } from "./stream-helper";
import {
CsvFile,
appendCsvData,
appendImageData,
appendSourceData,
} from "./stream-helper";
type LlamaIndexResponse =
| AgentStreamChatResponse<ToolCallLLMMessageOptions>
| Response;
type ParserOptions = {
image_url?: string;
export type DataParserOptions = {
imageUrl?: string;
csvFiles?: CsvFile[];
};
export const convertMessageContent = (
textMessage: string,
additionalData?: DataParserOptions,
): MessageContent => {
if (!additionalData) return textMessage;
const content: MessageContent = [
{
type: "text",
text: textMessage,
},
];
if (additionalData?.imageUrl) {
content.push({
type: "image_url",
image_url: {
url: additionalData?.imageUrl,
},
});
}
if (additionalData?.csvFiles?.length) {
const rawContents = additionalData.csvFiles.map((csv) => {
return "```csv\n" + csv.content + "\n```";
});
const csvContent =
"Use data from following CSV raw contents:\n" + rawContents.join("\n\n");
content.push({
type: "text",
text: `${csvContent}\n\n${textMessage}`,
});
}
return content;
};
function createParser(
res: AsyncIterable<LlamaIndexResponse>,
data: StreamData,
opts?: ParserOptions,
opts?: DataParserOptions,
) {
const it = res[Symbol.asyncIterator]();
const trimStartOfStream = trimStartOfStreamHelper();
@@ -34,7 +76,8 @@ function createParser(
let sourceNodes: NodeWithScore<Metadata>[] | undefined;
return new ReadableStream<string>({
start() {
appendImageData(data, opts?.image_url);
appendImageData(data, opts?.imageUrl);
appendCsvData(data, opts?.csvFiles);
},
async pull(controller): Promise<void> {
const { value, done } = await it.next();
@@ -72,7 +115,7 @@ export function LlamaIndexStream(
data: StreamData,
opts?: {
callbacks?: AIStreamCallbacksAndOptions;
parserOptions?: ParserOptions;
parserOptions?: DataParserOptions;
},
): ReadableStream<Uint8Array> {
return createParser(response, data, opts?.parserOptions)
@@ -17,6 +17,22 @@ export function appendImageData(data: StreamData, imageUrl?: string) {
});
}
function getNodeUrl(metadata: Metadata) {
const url = metadata["URL"];
if (url) return url;
const fileName = metadata["file_name"];
if (!process.env.FILESERVER_URL_PREFIX) {
console.warn(
"FILESERVER_URL_PREFIX is not set. File URLs will not be generated.",
);
return undefined;
}
if (fileName) {
return `${process.env.FILESERVER_URL_PREFIX}/data/${fileName}`;
}
return undefined;
}
export function appendSourceData(
data: StreamData,
sourceNodes?: NodeWithScore<Metadata>[],
@@ -29,6 +45,7 @@ export function appendSourceData(
...node.node.toMutableJSON(),
id: node.node.id_,
score: node.score ?? null,
url: getNodeUrl(node.node.metadata),
})),
},
});
@@ -65,6 +82,15 @@ export function appendToolData(
});
}
export function createStreamTimeout(stream: StreamData) {
const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes
const t = setTimeout(() => {
appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`);
stream.close();
}, timeout);
return t;
}
export function createCallbackManager(stream: StreamData) {
const callbackManager = new CallbackManager();
@@ -95,3 +121,20 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export function appendCsvData(data: StreamData, csvFiles?: CsvFile[]) {
if (!csvFiles) return;
data.appendMessageAnnotation({
type: "csv",
data: {
csvFiles,
},
});
}
@@ -1,3 +1,5 @@
import os
import logging
from pydantic import BaseModel
from typing import List, Any, Optional, Dict, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -11,6 +13,7 @@ from aiostream import stream
chat_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
class _Message(BaseModel):
role: MessageRole
@@ -38,14 +41,27 @@ class _SourceNodes(BaseModel):
metadata: Dict[str, Any]
score: Optional[float]
text: str
url: Optional[str]
@classmethod
def from_source_node(cls, source_node: NodeWithScore):
metadata = source_node.node.metadata
url = metadata.get("URL")
if not url:
file_name = metadata.get("file_name")
url_prefix = os.getenv("FILESERVER_URL_PREFIX")
if not url_prefix:
logger.warning("Warning: FILESERVER_URL_PREFIX not set in environment variables")
if file_name and url_prefix:
url = f"{url_prefix}/data/{file_name}"
return cls(
id=source_node.node.node_id,
metadata=source_node.node.metadata,
metadata=metadata,
score=source_node.score,
text=source_node.node.text, # type: ignore
url=url
)
@classmethod
@@ -14,8 +14,8 @@ fastapi = "^0.109.1"
uvicorn = { extras = ["standard"], version = "^0.23.2" }
python-dotenv = "^1.0.0"
aiostream = "^0.5.2"
llama-index = "0.10.28"
llama-index-core = "0.10.28"
llama-index = "0.10.41"
llama-index-core = "0.10.41"
cachetools = "^5.3.3"
[build-system]
@@ -6,6 +6,7 @@ import {
type AIStreamCallbacksAndOptions,
} from "ai";
import {
MessageContent,
Metadata,
NodeWithScore,
Response,
@@ -13,20 +14,61 @@ import {
} from "llamaindex";
import { AgentStreamChatResponse } from "llamaindex/agent/base";
import { appendImageData, appendSourceData } from "./stream-helper";
import {
CsvFile,
appendCsvData,
appendImageData,
appendSourceData,
} from "./stream-helper";
type LlamaIndexResponse =
| AgentStreamChatResponse<ToolCallLLMMessageOptions>
| Response;
type ParserOptions = {
image_url?: string;
export type DataParserOptions = {
imageUrl?: string;
csvFiles?: CsvFile[];
};
export const convertMessageContent = (
textMessage: string,
additionalData?: DataParserOptions,
): MessageContent => {
if (!additionalData) return textMessage;
const content: MessageContent = [
{
type: "text",
text: textMessage,
},
];
if (additionalData?.imageUrl) {
content.push({
type: "image_url",
image_url: {
url: additionalData?.imageUrl,
},
});
}
if (additionalData?.csvFiles?.length) {
const rawContents = additionalData.csvFiles.map((csv) => {
return "```csv\n" + csv.content + "\n```";
});
const csvContent =
"Use data from following CSV raw contents:\n" + rawContents.join("\n\n");
content.push({
type: "text",
text: `${csvContent}\n\n${textMessage}`,
});
}
return content;
};
function createParser(
res: AsyncIterable<LlamaIndexResponse>,
data: StreamData,
opts?: ParserOptions,
opts?: DataParserOptions,
) {
const it = res[Symbol.asyncIterator]();
const trimStartOfStream = trimStartOfStreamHelper();
@@ -34,7 +76,8 @@ function createParser(
let sourceNodes: NodeWithScore<Metadata>[] | undefined;
return new ReadableStream<string>({
start() {
appendImageData(data, opts?.image_url);
appendImageData(data, opts?.imageUrl);
appendCsvData(data, opts?.csvFiles);
},
async pull(controller): Promise<void> {
const { value, done } = await it.next();
@@ -72,7 +115,7 @@ export function LlamaIndexStream(
data: StreamData,
opts?: {
callbacks?: AIStreamCallbacksAndOptions;
parserOptions?: ParserOptions;
parserOptions?: DataParserOptions;
},
): ReadableStream<Uint8Array> {
return createParser(response, data, opts?.parserOptions)
@@ -1,11 +1,15 @@
import { initObservability } from "@/app/observability";
import { Message, StreamData, StreamingTextResponse } from "ai";
import { ChatMessage, MessageContent, Settings } from "llamaindex";
import { ChatMessage, Settings } from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { createChatEngine } from "./engine/chat";
import { initSettings } from "./engine/settings";
import { LlamaIndexStream } from "./llamaindex-stream";
import { createCallbackManager } from "./stream-helper";
import {
DataParserOptions,
LlamaIndexStream,
convertMessageContent,
} from "./llamaindex-stream";
import { createCallbackManager, createStreamTimeout } from "./stream-helper";
initObservability();
initSettings();
@@ -13,29 +17,17 @@ initSettings();
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const convertMessageContent = (
textMessage: string,
imageUrl: string | undefined,
): MessageContent => {
if (!imageUrl) return textMessage;
return [
{
type: "text",
text: textMessage,
},
{
type: "image_url",
image_url: {
url: imageUrl,
},
},
];
};
export async function POST(request: NextRequest) {
// Init Vercel AI StreamData and timeout
const vercelStreamData = new StreamData();
const streamTimeout = createStreamTimeout(vercelStreamData);
try {
const body = await request.json();
const { messages, data }: { messages: Message[]; data: any } = body;
const {
messages,
data,
}: { messages: Message[]; data: DataParserOptions | undefined } = body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
return NextResponse.json(
@@ -50,13 +42,7 @@ export async function POST(request: NextRequest) {
const chatEngine = await createChatEngine();
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
data?.imageUrl,
);
// Init Vercel AI StreamData
const vercelStreamData = new StreamData();
const userMessageContent = convertMessageContent(userMessage.content, data);
// Setup callbacks
const callbackManager = createCallbackManager(vercelStreamData);
@@ -73,7 +59,8 @@ export async function POST(request: NextRequest) {
// Transform LlamaIndex stream to Vercel/AI format
const stream = LlamaIndexStream(response, vercelStreamData, {
parserOptions: {
image_url: data?.imageUrl,
imageUrl: data?.imageUrl,
csvFiles: data?.csvFiles,
},
});
@@ -89,5 +76,7 @@ export async function POST(request: NextRequest) {
status: 500,
},
);
} finally {
clearTimeout(streamTimeout);
}
}
@@ -17,6 +17,22 @@ export function appendImageData(data: StreamData, imageUrl?: string) {
});
}
function getNodeUrl(metadata: Metadata) {
const url = metadata["URL"];
if (url) return url;
const fileName = metadata["file_name"];
if (!process.env.FILESERVER_URL_PREFIX) {
console.warn(
"FILESERVER_URL_PREFIX is not set. File URLs will not be generated.",
);
return undefined;
}
if (fileName) {
return `${process.env.FILESERVER_URL_PREFIX}/data/${fileName}`;
}
return undefined;
}
export function appendSourceData(
data: StreamData,
sourceNodes?: NodeWithScore<Metadata>[],
@@ -29,6 +45,7 @@ export function appendSourceData(
...node.node.toMutableJSON(),
id: node.node.id_,
score: node.score ?? null,
url: getNodeUrl(node.node.metadata),
})),
},
});
@@ -65,6 +82,15 @@ export function appendToolData(
});
}
export function createStreamTimeout(stream: StreamData) {
const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes
const t = setTimeout(() => {
appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`);
stream.close();
}, timeout);
return t;
}
export function createCallbackManager(stream: StreamData) {
const callbackManager = new CallbackManager();
@@ -95,3 +121,20 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export function appendCsvData(data: StreamData, csvFiles?: CsvFile[]) {
if (!csvFiles) return;
data.appendMessageAnnotation({
type: "csv",
data: {
csvFiles,
},
});
}
@@ -37,7 +37,7 @@ export default function ChatSection() {
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
isLoading={isLoading}
multiModal={true}
messages={messages}
/>
</div>
);
@@ -1,9 +1,13 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { Button } from "../button";
import FileUploader from "../file-uploader";
import { Input } from "../input";
import UploadCsvPreview from "../upload-csv-preview";
import UploadImagePreview from "../upload-image-preview";
import { ChatHandler } from "./chat.interface";
import { useCsv } from "./use-csv";
export default function ChatInput(
props: Pick<
@@ -14,11 +18,13 @@ export default function ChatInput(
| "onFileError"
| "handleSubmit"
| "handleInputChange"
> & {
multiModal?: boolean;
},
| "messages"
>,
) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const { files, uploadNew, removeFile, resetUploadedFiles } = useCsv(
props.messages,
);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (imageUrl) {
@@ -28,6 +34,15 @@ export default function ChatInput(
setImageUrl(null);
return;
}
if (files.length > 0) {
props.handleSubmit(e, {
data: { csvFiles: files },
});
resetUploadedFiles();
return;
}
props.handleSubmit(e);
};
@@ -43,11 +58,32 @@ export default function ChatInput(
setImageUrl(base64);
};
const handleUploadCsvFile = async (file: File) => {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const isSuccess = uploadNew({
id: uuidv4(),
content,
filename: file.name,
filesize: file.size,
});
if (!isSuccess) {
alert("File already exists in the list.");
}
};
const handleUploadFile = async (file: File) => {
try {
if (props.multiModal && file.type.startsWith("image/")) {
if (file.type.startsWith("image/")) {
return await handleUploadImageFile(file);
}
if (file.type === "text/csv") {
return await handleUploadCsvFile(file);
}
props.onFileUpload?.(file);
} catch (error: any) {
props.onFileError?.(error.message);
@@ -62,6 +98,30 @@ export default function ChatInput(
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} />
)}
{files.length > 0 && (
<div className="flex gap-4 w-full overflow-auto py-2">
{props.isLoading ? (
<div className="flex gap-2 items-center">
<Loader2 className="h-4 w-4 animate-spin" />{" "}
<span>Handling csv files...</span>
</div>
) : (
<>
{files.map((csv) => {
return (
<UploadCsvPreview
key={csv.id}
filename={csv.filename}
filesize={csv.filesize}
onRemove={() => removeFile(csv)}
isNew={csv.type === "new_upload"}
/>
);
})}
</>
)}
</div>
)}
<div className="flex w-full items-start justify-between gap-4 ">
<Input
autoFocus
@@ -8,14 +8,16 @@ import { ChatEvents } from "./chat-events";
import { ChatImage } from "./chat-image";
import { ChatSources } from "./chat-sources";
import ChatTools from "./chat-tools";
import CsvContent from "./csv-content";
import {
AnnotationData,
CsvData,
EventData,
ImageData,
MessageAnnotation,
MessageAnnotationType,
SourceData,
ToolData,
getAnnotationData,
} from "./index";
import Markdown from "./markdown";
import { useCopyToClipboard } from "./use-copy-to-clipboard";
@@ -25,13 +27,6 @@ type ContentDisplayConfig = {
component: JSX.Element | null;
};
function getAnnotationData<T extends AnnotationData>(
annotations: MessageAnnotation[],
type: MessageAnnotationType,
): T[] {
return annotations.filter((a) => a.type === type).map((a) => a.data as T);
}
function ChatMessageContent({
message,
isLoading,
@@ -46,6 +41,10 @@ function ChatMessageContent({
annotations,
MessageAnnotationType.IMAGE,
);
const csvData = getAnnotationData<CsvData>(
annotations,
MessageAnnotationType.CSV,
);
const eventData = getAnnotationData<EventData>(
annotations,
MessageAnnotationType.EVENTS,
@@ -61,16 +60,20 @@ function ChatMessageContent({
const contents: ContentDisplayConfig[] = [
{
order: -3,
order: -4,
component: imageData[0] ? <ChatImage data={imageData[0]} /> : null,
},
{
order: -2,
order: -3,
component:
eventData.length > 0 ? (
<ChatEvents isLoading={isLoading} data={eventData} />
) : null,
},
{
order: -2,
component: csvData[0] ? <CsvContent data={csvData[0]} /> : null,
},
{
order: -1,
component: toolData[0] ? <ChatTools data={toolData[0]} /> : null,
@@ -2,12 +2,10 @@ import { Check, Copy } from "lucide-react";
import { useMemo } from "react";
import { Button } from "../button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
import { getStaticFileDataUrl } from "../lib/url";
import { SourceData, SourceNode } from "./index";
import { SourceData } from "./index";
import { useCopyToClipboard } from "./use-copy-to-clipboard";
import PdfDialog from "./widgets/PdfDialog";
const DATA_SOURCE_FOLDER = "data";
const SCORE_THRESHOLD = 0.3;
function SourceNumberButton({ index }: { index: number }) {
@@ -18,46 +16,11 @@ function SourceNumberButton({ index }: { index: number }) {
);
}
enum NODE_TYPE {
URL,
FILE,
UNKNOWN,
}
type NodeInfo = {
id: string;
type: NODE_TYPE;
path?: string;
url?: string;
};
function getNodeInfo(node: SourceNode): NodeInfo {
if (typeof node.metadata["URL"] === "string") {
const url = node.metadata["URL"];
return {
id: node.id,
type: NODE_TYPE.URL,
path: url,
url,
};
}
if (typeof node.metadata["file_path"] === "string") {
const fileName = node.metadata["file_name"] as string;
const filePath = `${DATA_SOURCE_FOLDER}/${fileName}`;
return {
id: node.id,
type: NODE_TYPE.FILE,
path: node.metadata["file_path"],
url: getStaticFileDataUrl(filePath),
};
}
return {
id: node.id,
type: NODE_TYPE.UNKNOWN,
};
}
export function ChatSources({ data }: { data: SourceData }) {
const sources: NodeInfo[] = useMemo(() => {
// aggregate nodes by url or file_path (get the highest one by score)
@@ -67,8 +30,11 @@ export function ChatSources({ data }: { data: SourceData }) {
.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD)
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1))
.forEach((node) => {
const nodeInfo = getNodeInfo(node);
const key = nodeInfo.path ?? nodeInfo.id; // use id as key for UNKNOWN type
const nodeInfo = {
id: node.id,
url: node.url,
};
const key = nodeInfo.url ?? nodeInfo.id; // use id as key for UNKNOWN type
if (!nodesByPath[key]) {
nodesByPath[key] = nodeInfo;
}
@@ -84,13 +50,12 @@ export function ChatSources({ data }: { data: SourceData }) {
<span className="font-semibold">Sources:</span>
<div className="inline-flex gap-1 items-center">
{sources.map((nodeInfo: NodeInfo, index: number) => {
if (nodeInfo.path?.endsWith(".pdf")) {
if (nodeInfo.url?.endsWith(".pdf")) {
return (
<PdfDialog
key={nodeInfo.id}
documentId={nodeInfo.id}
url={nodeInfo.url!}
path={nodeInfo.path}
trigger={<SourceNumberButton index={index} />}
/>
);
@@ -116,16 +81,16 @@ export function ChatSources({ data }: { data: SourceData }) {
function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
if (nodeInfo.type !== NODE_TYPE.UNKNOWN) {
if (nodeInfo.url) {
// this is a node generated by the web loader or file loader,
// add a link to view its URL and a button to copy the URL to the clipboard
return (
<div className="flex items-center my-2">
<a className="hover:text-blue-900" href={nodeInfo.url} target="_blank">
<span>{nodeInfo.path}</span>
<span>{nodeInfo.url}</span>
</a>
<Button
onClick={() => copyToClipboard(nodeInfo.path!)}
onClick={() => copyToClipboard(nodeInfo.url!)}
size="icon"
variant="ghost"
className="h-12 w-12 shrink-0"
@@ -0,0 +1,16 @@
import { CsvData } from ".";
import CsvDialog from "./widgets/CsvDialog";
export default function CsvContent({ data }: { data: CsvData }) {
if (!data.csvFiles.length) return null;
return (
<div>
<p className="font-semibold mb-2">Using data from following CSV files:</p>
<div className="flex gap-2 items-center">
{data.csvFiles.map((csv, index) => (
<CsvDialog key={index} csv={csv} />
))}
</div>
</div>
);
}
@@ -6,6 +6,7 @@ export { type ChatHandler } from "./chat.interface";
export { ChatInput, ChatMessages };
export enum MessageAnnotationType {
CSV = "csv",
IMAGE = "image",
SOURCES = "sources",
EVENTS = "events",
@@ -16,11 +17,23 @@ export type ImageData = {
url: string;
};
export type CsvFile = {
content: string;
filename: string;
filesize: number;
id: string;
};
export type CsvData = {
csvFiles: CsvFile[];
};
export type SourceNode = {
id: string;
metadata: Record<string, unknown>;
score?: number;
text: string;
url?: string;
};
export type SourceData = {
@@ -46,9 +59,21 @@ export type ToolData = {
};
};
export type AnnotationData = ImageData | SourceData | EventData | ToolData;
export type AnnotationData =
| ImageData
| CsvData
| SourceData
| EventData
| ToolData;
export type MessageAnnotation = {
type: MessageAnnotationType;
data: AnnotationData;
};
export function getAnnotationData<T extends AnnotationData>(
annotations: MessageAnnotation[],
type: MessageAnnotationType,
): T[] {
return annotations.filter((a) => a.type === type).map((a) => a.data as T);
}
@@ -0,0 +1,85 @@
"use client";
import { Message } from "ai";
import { useEffect, useMemo, useState } from "react";
import {
CsvData,
CsvFile,
MessageAnnotation,
MessageAnnotationType,
getAnnotationData,
} from ".";
interface FrontendCSVData extends CsvFile {
type: "available" | "new_upload";
}
export function useCsv(messages: Message[]) {
const [availableFiles, setAvailableFiles] = useState<FrontendCSVData[]>([]);
const [uploadedFiles, setUploadedFiles] = useState<FrontendCSVData[]>([]);
const files = useMemo(() => {
return [...availableFiles, ...uploadedFiles];
}, [availableFiles, uploadedFiles]);
useEffect(() => {
const items = getAvailableCsvFiles(messages);
setAvailableFiles(items.map((data) => ({ ...data, type: "available" })));
}, [messages]);
const csvEqual = (a: CsvFile, b: CsvFile) => {
if (a.id === b.id) return true;
if (a.filename === b.filename && a.filesize === b.filesize) return true;
return false;
};
// Get available csv files from annotations chat history
// returns the unique csv files by id
const getAvailableCsvFiles = (messages: Message[]): Array<CsvFile> => {
const docHash: Record<string, CsvFile> = {};
messages.forEach((message) => {
if (message.annotations) {
const csvData = getAnnotationData<CsvData>(
message.annotations as MessageAnnotation[],
MessageAnnotationType.CSV,
);
csvData.forEach((data) => {
data.csvFiles.forEach((file) => {
if (!docHash[file.id]) {
docHash[file.id] = file;
}
});
});
}
});
return Object.values(docHash);
};
const uploadNew = (file: CsvFile) => {
const existedCsv = files.find((f) => csvEqual(f, file));
if (!existedCsv) {
setUploadedFiles((prev) => [...prev, { ...file, type: "new_upload" }]);
return true;
}
return false;
};
const removeFile = (file: FrontendCSVData) => {
if (file.type === "new_upload") {
setUploadedFiles((prev) => prev.filter((f) => f.id !== file.id));
} else {
setAvailableFiles((prev) => prev.filter((f) => f.id !== file.id));
}
};
const resetUploadedFiles = () => {
setUploadedFiles([]);
};
return {
files,
uploadNew,
removeFile,
resetUploadedFiles,
};
}
@@ -0,0 +1,62 @@
import Image from "next/image";
import { CsvFile } from "..";
import SheetIcon from "../../../ui/icons/sheet.svg";
import { Button } from "../../button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../../drawer";
export interface CsvDialogProps {
csv: CsvFile;
}
export default function CsvDialog(props: CsvDialogProps) {
const { filename, filesize, content } = props.csv;
const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10;
return (
<Drawer direction="left">
<DrawerTrigger asChild>
<div
className="border-2 border-green-700 py-1.5 px-3 rounded-lg flex gap-2 items-center cursor-pointer text-sm hover:bg-green-700 hover:text-white transition-colors duration-200 ease-in-out"
key={filename}
>
<div className="h-4 w-4 shrink-0 rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
/>
</div>
<span>
{filename} - {fileSizeInKB} KB
</span>
</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>Csv Raw Content</DrawerTitle>
<DrawerDescription>
{filename} ({fileSizeInKB} KB)
</DrawerDescription>
</div>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerHeader>
<div className="m-4 max-h-[80%] overflow-auto">
<pre className="bg-secondary rounded-md p-4 block text-sm">
{content}
</pre>
</div>
</DrawerContent>
</Drawer>
);
}
@@ -12,7 +12,6 @@ import {
export interface PdfDialogProps {
documentId: string;
path: string;
url: string;
trigger: React.ReactNode;
}
@@ -26,13 +25,13 @@ export default function PdfDialog(props: PdfDialogProps) {
<div className="space-y-2">
<DrawerTitle>PDF Content</DrawerTitle>
<DrawerDescription>
File path:{" "}
File URL:{" "}
<a
className="hover:text-blue-900"
href={props.url}
target="_blank"
>
{props.path}
{props.url}
</a>
</DrawerDescription>
</div>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="49px" height="67px" viewBox="0 0 49 67" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Sheets-icon</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-1"></path>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-3"></path>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-5"></path>
<linearGradient x1="50.0053945%" y1="8.58610612%" x2="50.0053945%" y2="100.013939%" id="linearGradient-7">
<stop stop-color="#263238" stop-opacity="0.2" offset="0%"></stop>
<stop stop-color="#263238" stop-opacity="0.02" offset="100%"></stop>
</linearGradient>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-8"></path>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-10"></path>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-12"></path>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-14"></path>
<radialGradient cx="3.16804688%" cy="2.71744318%" fx="3.16804688%" fy="2.71744318%" r="161.248516%" gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)" id="radialGradient-16">
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop>
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"></stop>
</radialGradient>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Consumer-Apps-Sheets-Large-VD-R8-" transform="translate(-451.000000, -451.000000)">
<g id="Hero" transform="translate(0.000000, 63.000000)">
<g id="Personal" transform="translate(277.000000, 299.000000)">
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
<g id="Group">
<g id="Clipped">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z" id="Path" fill="#0F9D58" fill-rule="nonzero" mask="url(#mask-2)"></path>
</g>
<g id="Clipped">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z" id="Shape" fill="#F1F1F1" fill-rule="nonzero" mask="url(#mask-4)"></path>
</g>
<g id="Clipped">
<mask id="mask-6" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<g id="SVGID_1_"></g>
<polygon id="Path" fill="url(#linearGradient-7)" fill-rule="nonzero" mask="url(#mask-6)" points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"></polygon>
</g>
<g id="Clipped">
<mask id="mask-9" fill="white">
<use xlink:href="#path-8"></use>
</mask>
<g id="SVGID_1_"></g>
<g id="Group" mask="url(#mask-9)">
<g transform="translate(26.625000, -2.958333)">
<path d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z" id="Path" fill="#87CEAC" fill-rule="nonzero"></path>
</g>
</g>
</g>
<g id="Clipped">
<mask id="mask-11" fill="white">
<use xlink:href="#path-10"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z" id="Path" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-11)"></path>
</g>
<g id="Clipped">
<mask id="mask-13" fill="white">
<use xlink:href="#path-12"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z" id="Path" fill-opacity="0.2" fill="#263238" fill-rule="nonzero" mask="url(#mask-13)"></path>
</g>
<g id="Clipped">
<mask id="mask-15" fill="white">
<use xlink:href="#path-14"></use>
</mask>
<g id="SVGID_1_"></g>
<path d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z" id="Path" fill-opacity="0.1" fill="#263238" fill-rule="nonzero" mask="url(#mask-15)"></path>
</g>
</g>
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="Path" fill="url(#radialGradient-16)" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -1,11 +0,0 @@
const staticFileAPI = "/api/files";
export const getStaticFileDataUrl = (filePath: string) => {
const isUsingBackend = !!process.env.NEXT_PUBLIC_CHAT_API;
const fileUrl = `${staticFileAPI}/${filePath}`;
if (isUsingBackend) {
const backendOrigin = new URL(process.env.NEXT_PUBLIC_CHAT_API!).origin;
return `${backendOrigin}${fileUrl}`;
}
return fileUrl;
};
@@ -0,0 +1,55 @@
import { XCircleIcon } from "lucide-react";
import Image from "next/image";
import SheetIcon from "../ui/icons/sheet.svg";
import { cn } from "./lib/utils";
export default function UploadCsvPreview({
filename,
filesize,
onRemove,
isNew,
}: {
filename: string;
filesize: number;
onRemove: () => void;
isNew?: boolean;
}) {
const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10;
return (
<div className="p-2 w-60 max-w-60 bg-secondary rounded-lg text-sm relative">
<div className="flex flex-row items-center gap-2">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
/>
</div>
<div className="overflow-hidden">
<div className="truncate font-semibold">
{filename} ({fileSizeInKB} KB)
</div>
<div className="truncate text-token-text-tertiary flex items-center gap-2">
<span>Spreadsheet</span>
{isNew && (
<span className="px-2 py-0.5 bg-red-400 text-white text-xs rounded-2xl">
new
</span>
)}
</div>
</div>
</div>
<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>
);
}
@@ -35,7 +35,8 @@
"tailwind-merge": "^2.1.0",
"vaul": "^0.9.1",
"@llamaindex/pdf-viewer": "^1.1.1",
"@e2b/code-interpreter": "^0.0.5"
"@e2b/code-interpreter": "^0.0.5",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.10.3",
@@ -52,6 +53,7 @@
"prettier-plugin-organize-imports": "^3.2.4",
"tailwindcss": "^3.3.6",
"tsx": "^4.7.2",
"typescript": "^5.3.2"
"typescript": "^5.3.2",
"@types/uuid": "^9.0.8"
}
}