Compare commits

...

29 Commits

Author SHA1 Message Date
Thuc Pham 3d3d94ca33 feat: limit display 2024-05-30 10:03:32 +07:00
Thuc Pham b0e25b3d7d fix lint 2024-05-29 08:03:11 +00:00
Thuc Pham ce2de2d0a8 update convert message content 2024-05-29 07:58:15 +00:00
Thuc Pham b3f3ce403f resolve conflict 2024-05-29 08:41:05 +07:00
Thuc Pham d473867a9a get resouces inside chat input 2024-05-29 08:26:17 +07:00
Thuc Pham 5068e28c8e Merge branch 'main' into feat/implement-csv-upload 2024-05-29 08:10:11 +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 04cc7ce451 suggest csv resources from frontend annotation data 2024-05-27 17:45:16 +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 bafb1d7d71 remove multi modal prop 2024-05-27 11:12:20 +07:00
Marcus Schiesser 2eb2803284 Create bright-turkeys-melt.md 2024-05-24 19:03:59 +07:00
Thuc Pham 48188ca3f9 feat: construct resource url from backend 2024-05-24 14:40:44 +07:00
Thuc Pham beaf4b4e90 add timeout for streaming 2024-05-24 10:54:52 +07:00
Thuc Pham c24e60a427 Update pre-commit 2024-05-24 09:15:28 +07:00
Thuc Pham 14c0b2ea87 Update .gitignore 2024-05-24 09:15:13 +07:00
Thuc Pham f3c1e54e97 fix lint 2024-05-24 09:12:55 +07:00
Thuc Pham 667c122eed update icon & fix code review 2024-05-24 09:09:00 +07:00
Thuc Pham cc45225a4a fix lint 2024-05-24 08:51:23 +07:00
Thuc Pham 5d190195b6 remove dead code 2024-05-24 08:49:16 +07:00
Thuc Pham 2e47af86ce feat: implement csv upload 2024-05-24 08:43:51 +07:00
Thuc Pham 778bdfaa0b fix: typo 2024-05-24 08:43:51 +07:00
Thuc Pham 2e43ba1615 refactor: use local file system, use absolute resource url 2024-05-24 08:42:46 +07:00
Thuc Pham ff8bed62c7 build tool system prompt 2024-05-24 08:39:38 +07:00
Thuc Pham a9d2177dc3 feat: implement interpreter tool 2024-05-24 08:39:00 +07:00
23 changed files with 601 additions and 157 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"create-llama": patch
---
Add CSV upload
+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",
},
];
};
-1
View File
@@ -159,7 +159,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>;
} = {
@@ -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;
@@ -13,6 +15,7 @@ export interface ChatInputProps {
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,
uploadedCsv: data?.uploadedCsv,
},
});
@@ -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 {
UploadedCsv,
appendCsvData,
appendImageData,
appendSourceData,
} from "./stream-helper";
type LlamaIndexResponse =
| AgentStreamChatResponse<ToolCallLLMMessageOptions>
| Response;
type ParserOptions = {
image_url?: string;
export type DataParserOptions = {
imageUrl?: string;
uploadedCsv?: UploadedCsv;
};
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?.uploadedCsv) {
const csvContent =
"Use the following CSV data:\n" +
"```csv\n" +
additionalData.uploadedCsv.content +
"\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?.uploadedCsv);
},
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,17 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type UploadedCsv = {
content: string;
filename: string;
filesize: number;
};
export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) {
if (!uploadedCsv) return;
data.appendMessageAnnotation({
type: "csv",
data: uploadedCsv,
});
}
@@ -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
@@ -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 {
UploadedCsv,
appendCsvData,
appendImageData,
appendSourceData,
} from "./stream-helper";
type LlamaIndexResponse =
| AgentStreamChatResponse<ToolCallLLMMessageOptions>
| Response;
type ParserOptions = {
image_url?: string;
export type DataParserOptions = {
imageUrl?: string;
uploadedCsv?: UploadedCsv;
};
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?.uploadedCsv) {
const csvContent =
"Use the following CSV data:\n" +
"```csv\n" +
additionalData.uploadedCsv.content +
"\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?.uploadedCsv);
},
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,
uploadedCsv: data?.uploadedCsv,
},
});
@@ -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,17 @@ export function createCallbackManager(stream: StreamData) {
return callbackManager;
}
export type UploadedCsv = {
content: string;
filename: string;
filesize: number;
};
export function appendCsvData(data: StreamData, uploadedCsv?: UploadedCsv) {
if (!uploadedCsv) return;
data.appendMessageAnnotation({
type: "csv",
data: uploadedCsv,
});
}
@@ -37,7 +37,7 @@ export default function ChatSection() {
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
isLoading={isLoading}
multiModal={true}
messages={messages}
/>
</div>
);
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { CsvData, getInputResources } from ".";
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 ChatResources from "./chat-resources";
import { ChatHandler } from "./chat.interface";
export default function ChatInput(
@@ -14,11 +17,21 @@ export default function ChatInput(
| "onFileError"
| "handleSubmit"
| "handleInputChange"
> & {
multiModal?: boolean;
},
| "messages"
>,
) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploadedCsv, setUploadedCsv] = useState<CsvData>();
const [inputResources, setInputResources] = useState<
Array<CsvData & { selected: boolean }>
>([]);
useEffect(() => {
const resources = getInputResources(props.messages);
setInputResources(
resources.csv.map((data) => ({ ...data, selected: true })),
);
}, [props.messages]);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (imageUrl) {
@@ -28,6 +41,24 @@ export default function ChatInput(
setImageUrl(null);
return;
}
// if users upload a new csv file, we will send it to backend
if (uploadedCsv) {
props.handleSubmit(e, {
data: { uploadedCsv },
});
setUploadedCsv(undefined);
return;
}
// if users upload a new csv file, we can reuse provided csv resources
const attachCsv = inputResources.filter((r) => r.selected)[0];
if (attachCsv) {
props.handleSubmit(e, {
data: { uploadedCsv: attachCsv },
});
return;
}
props.handleSubmit(e);
};
@@ -43,25 +74,62 @@ 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);
});
setUploadedCsv({
content,
filename: file.name,
filesize: file.size,
});
};
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);
}
};
const removeResource = (index: number) => {
setInputResources((resources) => {
const newResources = [...resources];
newResources[index].selected = false;
return newResources;
});
};
return (
<form
onSubmit={onSubmit}
className="rounded-xl bg-white p-4 shadow-xl space-y-4"
>
<ChatResources
isLoading={props.isLoading}
resources={inputResources}
removeResource={removeResource}
/>
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} />
)}
{uploadedCsv && (
<UploadCsvPreview
filename={uploadedCsv.filename}
filesize={uploadedCsv.filesize}
onRemove={() => setUploadedCsv(undefined)}
/>
)}
<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,
@@ -0,0 +1,48 @@
import { Loader2, XIcon } from "lucide-react";
import Image from "next/image";
import { CsvData } from ".";
import SheetIcon from "../../ui/icons/sheet.svg";
export interface ChatResourcesProps {
isLoading: boolean;
resources: Array<CsvData & { selected: boolean }>;
removeResource: (index: number) => void;
}
export default function ChatResources(props: ChatResourcesProps) {
if (!props.resources.length) return null;
return (
<div className="flex gap-4 text-sm">
{props.resources.map((data, index) => {
if (!data.selected) return null;
const fileSizeInKB = Math.round((data.filesize / 1024) * 10) / 10;
return (
<div
className="border-2 border-green-700 py-2 px-3 rounded-lg flex gap-2 items-center"
key={data.filename}
>
<div className="h-4 w-4 shrink-0 rounded-md">
<Image
className="h-full w-auto"
priority
src={SheetIcon}
alt="SheetIcon"
/>
</div>
<span>
{data.filename} - {fileSizeInKB} KB
</span>
{props.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<XIcon
className="w-4 h-4 cursor-pointer"
onClick={() => props.removeResource(index)}
/>
)}
</div>
);
})}
</div>
);
}
@@ -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,15 @@
import { CsvData } from ".";
const LIMIT_DISPLAY = 100; // Limit the display of CSV content to 100 characters
export default function CsvContent({ data }: { data: CsvData }) {
const summaryContent = data.content.slice(0, LIMIT_DISPLAY) + "...";
return (
<div className="space-y-2">
<h3 className="font-semibold">CSV Raw Content</h3>
<pre className="bg-secondary max-h-[200px] overflow-auto rounded-md p-4 block text-sm">
{summaryContent}
</pre>
</div>
);
}
@@ -1,4 +1,4 @@
import { JSONValue } from "ai";
import { JSONValue, Message } from "ai";
import ChatInput from "./chat-input";
import ChatMessages from "./chat-messages";
@@ -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,18 @@ export type ImageData = {
url: string;
};
export type CsvData = {
content: string;
filename: string;
filesize: number;
};
export type SourceNode = {
id: string;
metadata: Record<string, unknown>;
score?: number;
text: string;
url?: string;
};
export type SourceData = {
@@ -46,9 +54,50 @@ 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);
}
// this function is used to get the additional resources for a message
// it filters the annotations of a message and returns the unique resources
// currently only CSV resources are supported
export const getInputResources = (
messages: Message[],
): {
csv: Array<CsvData>;
} => {
const csvResources: CsvData[] = [];
messages.forEach((message) => {
if (message.annotations) {
const csvData = getAnnotationData<CsvData>(
message.annotations as MessageAnnotation[],
MessageAnnotationType.CSV,
);
csvData.forEach((data) => {
if (
csvResources.findIndex((r) => r.filename === data.filename) === -1
) {
csvResources.push(data);
}
});
}
});
return {
csv: csvResources,
};
};
@@ -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,46 @@
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,
}: {
filename: string;
filesize: number;
onRemove: () => void;
}) {
const fileSizeInKB = Math.round((filesize / 1024) * 10) / 10;
return (
<div className="p-2 w-80 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">Spreadsheet</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>
);
}