feat: use create-llama chat session (#94)

---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
Thuc Pham
2024-06-19 22:17:44 +07:00
committed by GitHub
parent 394733b25f
commit 5c16a343ea
68 changed files with 8402 additions and 7005 deletions
-4
View File
@@ -1,4 +0,0 @@
# Your openai api key. (required)
OPENAI_API_KEY=sk-xxxx
# Allow all OpenAI models (not only gpt 3.5)
ALLOW_ALL_MODELS=true
+5
View File
@@ -46,3 +46,8 @@ dev
*.key.pub
# Sentry Config File
.sentryclirc
# create-llama copies
app/api/chat/config/
app/api/files/
cl/
+10 -19
View File
@@ -41,18 +41,22 @@ git clone https://github.com/run-llama/chat-llamaindex
cd chat-llamaindex
```
- Set the environment variables
- Prepare the project
```bash
cp .env.template .env.development.local
pnpm install
pnpm run create-llama
```
Edit environment variables in `.env.development.local`.
> **Note**: The last step copies the chat UI component and file server route from the [create-llama](https://github.com/run-llama/create-llama) project, see [./create-llama.sh](./create-llama.sh).
- Set the environment variables
Edit environment variables in `.env.development.local`. Especially check your `OPENAI_API_KEY`.
- Run the dev server
```bash
pnpm install
pnpm dev
```
@@ -60,21 +64,6 @@ pnpm dev
You can use Docker for development and deployment of LlamaIndex Chat.
- Clone the repository
```bash
git clone https://github.com/run-llama/chat-llamaindex
cd chat-llamaindex
```
- Set the environment variables
```bash
cp .env.template .env.development.local
```
Edit environment variables in `.env.development.local`.
#### Building the Docker Image
```bash
@@ -134,6 +123,8 @@ pnpm run generate <datasource-name>
Where `<datasource-name>` is the name of the subfolder with your data files.
> **Note**: On Windows, use `pnpm run generate:win <datasource-name>` instead.
## 🙏 Thanks
Thanks go to @Yidadaa for his [ChatGPT-Next-Web](https://github.com/Yidadaa/ChatGPT-Next-Web) project, which was used as a starter template for this project.
+30
View File
@@ -0,0 +1,30 @@
import { ContextChatEngine, Settings, SimpleChatEngine } from "llamaindex";
import { getDataSource } from "./index";
import { STORAGE_CACHE_DIR } from "./shared";
interface ChatEngineOptions {
datasource?: string;
}
export async function createChatEngine({ datasource }: ChatEngineOptions) {
if (datasource) {
const index = await getDataSource(datasource);
if (!index) {
throw new Error(
`No datasources found in storage cache folder: ${STORAGE_CACHE_DIR}/${datasource}. Run generate it first.`,
);
}
const retriever = index.asRetriever({
similarityTopK: process.env.TOP_K ? parseInt(process.env.TOP_K) : 3,
});
return new ContextChatEngine({
chatModel: Settings.llm,
retriever,
systemPrompt: process.env.SYSTEM_PROMPT,
});
}
return new SimpleChatEngine({
llm: Settings.llm,
});
}
+45
View File
@@ -0,0 +1,45 @@
import { VectorStoreIndex } from "llamaindex";
import { storageContextFromDefaults } from "llamaindex/storage/StorageContext";
import * as dotenv from "dotenv";
import { getDocuments } from "./loader";
import { initSettings } from "./settings";
import { STORAGE_CACHE_DIR } from "./shared";
// Load environment variables from local .env.development.local file
dotenv.config({ path: ".env.development.local" });
async function getRuntime(func: any) {
const start = Date.now();
await func();
const end = Date.now();
return end - start;
}
async function generateDatasource() {
const datasource = process.argv[2];
if (!datasource) {
console.error("Please provide a datasource as an argument.");
process.exit(1);
}
console.log(`Generating storage context for datasource '${datasource}'...`);
// Split documents, create embeddings and store them in the storage context
const ms = await getRuntime(async () => {
const storageContext = await storageContextFromDefaults({
persistDir: `${STORAGE_CACHE_DIR}/${datasource}`,
});
const documents = await getDocuments();
await VectorStoreIndex.fromDocuments(documents, {
storageContext,
});
});
console.log(`Storage context successfully generated in ${ms / 1000}s.`);
}
(async () => {
initSettings();
await generateDatasource();
console.log("Finished generating storage.");
})();
+20
View File
@@ -0,0 +1,20 @@
import { SimpleDocumentStore, VectorStoreIndex } from "llamaindex";
import { storageContextFromDefaults } from "llamaindex/storage/StorageContext";
import { STORAGE_CACHE_DIR } from "./shared";
export async function getDataSource(datasource: string) {
console.log(`Using datasource: ${datasource}`);
const storageContext = await storageContextFromDefaults({
persistDir: `${STORAGE_CACHE_DIR}/${datasource}`,
});
const numberOfDocs = Object.keys(
(storageContext.docStore as SimpleDocumentStore).toDict(),
).length;
if (numberOfDocs === 0) {
return null;
}
return await VectorStoreIndex.init({
storageContext,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { SimpleDirectoryReader } from "llamaindex";
export const DATA_DIR = "./datasources";
export async function getDocuments() {
return await new SimpleDirectoryReader().loadData({
directoryPath: DATA_DIR,
});
}
+94
View File
@@ -0,0 +1,94 @@
import {
Anthropic,
GEMINI_EMBEDDING_MODEL,
GEMINI_MODEL,
Gemini,
GeminiEmbedding,
OpenAI,
OpenAIEmbedding,
Settings,
} from "llamaindex";
import { HuggingFaceEmbedding } from "llamaindex/embeddings/HuggingFaceEmbedding";
import { OllamaEmbedding } from "llamaindex/embeddings/OllamaEmbedding";
import { ALL_AVAILABLE_ANTHROPIC_MODELS } from "llamaindex/llm/anthropic";
import { Ollama } from "llamaindex/llm/ollama";
const CHUNK_SIZE = 512;
const CHUNK_OVERLAP = 20;
export const initSettings = async () => {
// HINT: you can delete the initialization code for unused model providers
console.log(`Using '${process.env.MODEL_PROVIDER}' model provider`);
if (!process.env.MODEL || !process.env.EMBEDDING_MODEL) {
throw new Error("'MODEL' and 'EMBEDDING_MODEL' env variables must be set.");
}
switch (process.env.MODEL_PROVIDER) {
case "ollama":
initOllama();
break;
case "anthropic":
initAnthropic();
break;
case "gemini":
initGemini();
break;
default:
initOpenAI();
break;
}
Settings.chunkSize = CHUNK_SIZE;
Settings.chunkOverlap = CHUNK_OVERLAP;
};
function initOpenAI() {
Settings.llm = new OpenAI({
model: process.env.MODEL ?? "gpt-3.5-turbo",
maxTokens: process.env.LLM_MAX_TOKENS
? Number(process.env.LLM_MAX_TOKENS)
: undefined,
});
Settings.embedModel = new OpenAIEmbedding({
model: process.env.EMBEDDING_MODEL,
dimensions: process.env.EMBEDDING_DIM
? parseInt(process.env.EMBEDDING_DIM)
: undefined,
});
}
function initOllama() {
const config = {
host: process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434",
};
Settings.llm = new Ollama({
model: process.env.MODEL ?? "",
config,
});
Settings.embedModel = new OllamaEmbedding({
model: process.env.EMBEDDING_MODEL ?? "",
config,
});
}
function initAnthropic() {
const embedModelMap: Record<string, string> = {
"all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2",
"all-mpnet-base-v2": "Xenova/all-mpnet-base-v2",
};
Settings.llm = new Anthropic({
model: process.env.MODEL as keyof typeof ALL_AVAILABLE_ANTHROPIC_MODELS,
});
Settings.embedModel = new HuggingFaceEmbedding({
modelType: embedModelMap[process.env.EMBEDDING_MODEL!],
});
}
function initGemini() {
Settings.llm = new Gemini({
model: process.env.MODEL as GEMINI_MODEL,
});
Settings.embedModel = new GeminiEmbedding({
model: process.env.EMBEDDING_MODEL as GEMINI_EMBEDDING_MODEL,
});
}
+1
View File
@@ -0,0 +1 @@
export const STORAGE_CACHE_DIR = "./cache";
+115
View File
@@ -0,0 +1,115 @@
import { Message, StreamData, StreamingTextResponse } from "ai";
import {
ChatMessage,
OpenAI,
Settings,
SimpleChatHistory,
SummaryChatHistory,
} from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { createChatEngine } from "./engine/chat";
import { initSettings } from "./engine/settings";
import {
LlamaIndexStream,
convertMessageContent,
} from "@/cl/app/api/chat/llamaindex-stream";
import {
createCallbackManager,
createStreamTimeout,
} from "@/cl/app/api/chat/stream-helper";
import { LLMConfig } from "@/app/store/bot";
initSettings();
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface ChatRequestBody {
messages: Message[];
context: Message[];
modelConfig: LLMConfig;
datasource?: string;
}
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, context, modelConfig, datasource } =
body as ChatRequestBody;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
return NextResponse.json(
{
error:
"messages are required in the request body and the last message must be from the user",
},
{ status: 400 },
);
}
// Create chat engine instance with llm config from request
const llm = new OpenAI(modelConfig);
const chatEngine = await Settings.withLLM(llm, async () => {
return await createChatEngine({ datasource });
});
let annotations = userMessage.annotations;
if (!annotations) {
// the user didn't send any new annotations with the last message
// so use the annotations from the last user message that has annotations
// REASON: GPT4 doesn't consider MessageContentDetail from previous messages, only strings
annotations = messages
.slice()
.reverse()
.find(
(message) => message.role === "user" && message.annotations,
)?.annotations;
}
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
annotations,
);
// Setup callbacks
const callbackManager = createCallbackManager(vercelStreamData);
// Append context messages to the top of the chat history
const chatMessages = context.concat(messages) as ChatMessage[];
const chatHistory = modelConfig.sendMemory
? new SummaryChatHistory({ messages: chatMessages, llm })
: new SimpleChatHistory({ messages: chatMessages });
// Calling LlamaIndex's ChatEngine to get a streamed response
const response = await Settings.withCallbackManager(callbackManager, () => {
return chatEngine.chat({
message: userMessageContent,
chatHistory,
stream: true,
});
});
// Transform LlamaIndex stream to Vercel/AI format
const stream = LlamaIndexStream(response, vercelStreamData);
// Return a StreamingTextResponse, which can be consumed by the Vercel/AI client
return new StreamingTextResponse(stream, {}, vercelStreamData);
} catch (error) {
console.error("[LlamaIndex]", error);
return NextResponse.json(
{
detail: (error as Error).message,
},
{
status: 500,
},
);
} finally {
clearTimeout(streamTimeout);
}
}
-78
View File
@@ -1,78 +0,0 @@
import { unified } from "unified";
import parse from "rehype-parse";
import rehype2remark from "rehype-remark";
import stringify from "remark-stringify";
import axios from "axios";
import pdf from "pdf-parse";
import { remove } from "unist-util-remove";
import { URLDetailContent } from "@/app/client/fetch/url";
function removeCommentsAndTables() {
return (tree: any) => {
remove(tree, { type: "comment" });
remove(tree, { tagName: "table" });
};
}
async function htmlToMarkdown(html: string): Promise<string> {
const processor = unified()
.use(parse) // Parse the HTML
.use(removeCommentsAndTables) // Remove comment nodes
.use(rehype2remark as any) // Convert it to Markdown
.use(stringify); // Stringify the Markdown
const file = await processor.process(html);
return String(file);
}
export async function fetchContentFromURL(
url: string,
): Promise<URLDetailContent> {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failure fetching content from provided URL");
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/html")) {
const htmlContent = await response.text();
const markdownContent = await htmlToMarkdown(htmlContent);
return {
url,
content: markdownContent,
size: htmlContent.length,
type: "text/html",
};
}
if (contentType.includes("application/pdf")) {
const response = await axios.get(url, {
responseType: "arraybuffer",
});
const pdfBuffer = response.data;
const pdfData = await pdf(pdfBuffer);
const result = {
url,
content: pdfData.text,
size: pdfData.text.length,
type: "application/pdf",
} as URLDetailContent;
return result;
}
throw new Error("URL provided is not a PDF or HTML document");
}
export const getPDFContentFromBuffer = async (pdfBuffer: Buffer) => {
const data = await pdf(pdfBuffer);
const content = data.text;
const size = data.text.length;
return {
content,
size,
type: "application/pdf",
};
};
-34
View File
@@ -1,34 +0,0 @@
import { Embedding } from "@/app/client/fetch/url";
import {
DATASOURCES_CHUNK_OVERLAP,
DATASOURCES_CHUNK_SIZE,
} from "@/scripts/constants.mjs";
import {
Document,
MetadataMode,
OpenAIEmbedding,
SentenceSplitter,
SimpleNodeParser,
VectorStoreIndex,
serviceContextFromDefaults,
} from "llamaindex";
export default async function splitAndEmbed(
document: string,
): Promise<Embedding[]> {
const embedModel = new OpenAIEmbedding();
const nodeParser = new SimpleNodeParser({
chunkSize: DATASOURCES_CHUNK_SIZE,
chunkOverlap: DATASOURCES_CHUNK_OVERLAP,
});
const nodes = nodeParser.getNodesFromDocuments([
new Document({ text: document }),
]);
const texts = nodes.map((node) => node.getContent(MetadataMode.EMBED));
const embeddings = await embedModel.getTextEmbeddingsBatch(texts);
return nodes.map((node, i) => ({
text: node.getContent(MetadataMode.NONE),
embedding: embeddings[i],
}));
}
-99
View File
@@ -1,99 +0,0 @@
import {
fetchContentFromURL,
getPDFContentFromBuffer,
} from "@/app/api/fetch/content";
import { NextResponse, NextRequest } from "next/server";
import splitAndEmbed from "./embeddings";
import { URLDetailContent } from "@/app/client/fetch/url";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const site = searchParams.get("site");
if (!site) {
return NextResponse.json(
{ error: "Missing site parameter" },
{ status: 400 },
);
}
try {
const urlContent = await fetchContentFromURL(site);
urlContent.embeddings = await splitAndEmbed(urlContent.content!);
return NextResponse.json(urlContent);
} catch (error) {
console.error("[Fetch]", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 },
);
}
}
async function handleText(
fileName: string,
text: string,
): Promise<URLDetailContent> {
const embeddings = await splitAndEmbed(text);
return {
content: text,
embeddings: embeddings,
url: fileName,
size: text.length,
type: "text/plain",
};
}
async function handlePDF(
fileName: string,
pdf: string,
): Promise<URLDetailContent> {
const pdfBuffer = Buffer.from(pdf, "base64");
const pdfData = await getPDFContentFromBuffer(pdfBuffer);
const embeddings = await splitAndEmbed(pdfData.content);
return {
content: pdfData.content,
embeddings: embeddings,
size: pdfData.size,
type: "application/pdf",
url: fileName,
};
}
type Input = {
fileName: string;
pdf?: string;
text?: string;
};
export async function POST(request: NextRequest) {
try {
const { fileName, pdf, text }: Input = await request.json();
if (!fileName && (!pdf || !text)) {
return NextResponse.json(
{
error:
"filename and either text or pdf is required in the request body",
},
{ status: 400 },
);
}
const json = await (pdf
? handlePDF(fileName, pdf)
: handleText(fileName, text!));
return NextResponse.json(json);
} catch (error) {
console.error("[Fetch]", error);
return NextResponse.json(
{
error: (error as Error).message,
},
{
status: 500,
},
);
}
}
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-29
View File
@@ -1,29 +0,0 @@
import { DATASOURCES_CACHE_DIR } from "@/scripts/constants.mjs";
import {
VectorStoreIndex,
storageContextFromDefaults,
ServiceContext,
SimpleDocumentStore,
} from "llamaindex";
export async function getDataSource(
serviceContext: ServiceContext,
datasource: string,
) {
let storageContext = await storageContextFromDefaults({
persistDir: `${DATASOURCES_CACHE_DIR}/${datasource}`,
});
const numberOfDocs = Object.keys(
(storageContext.docStore as SimpleDocumentStore).toDict(),
).length;
if (numberOfDocs === 0) {
throw new Error(
`StorageContext for datasource '${datasource}' is empty - make sure to generate the datasource first`,
);
}
return await VectorStoreIndex.init({
storageContext,
serviceContext,
});
}
-217
View File
@@ -1,217 +0,0 @@
import {
ChatHistory,
ChatMessage,
ContextChatEngine,
OpenAI,
ServiceContext,
SimpleChatEngine,
SimpleChatHistory,
SummaryChatHistory,
TextNode,
serviceContextFromDefaults,
Response,
VectorStoreIndex,
} from "llamaindex";
import { IndexDict } from "llamaindex/indices/json-to-index-struct";
import { NextRequest, NextResponse } from "next/server";
import { LLMConfig, MessageContent } from "@/app/client/platforms/llm";
import { getDataSource } from "./datasource";
import {
DATASOURCES_CHUNK_OVERLAP,
DATASOURCES_CHUNK_SIZE,
} from "@/scripts/constants.mjs";
import { Embedding } from "@/app/client/fetch/url";
import Locale from "@/app/locales";
async function createChatEngine(
serviceContext: ServiceContext,
datasource?: string,
embeddings?: Embedding[],
) {
if (datasource || embeddings) {
let index;
if (embeddings) {
// TODO: merge indexes, currently we prefer own embeddings
index = await createIndex(serviceContext, embeddings);
} else if (datasource) {
index = await getDataSource(serviceContext, datasource);
}
const retriever = index!.asRetriever();
retriever.similarityTopK = 5;
return new ContextChatEngine({
chatModel: serviceContext.llm,
retriever,
});
}
return new SimpleChatEngine({
llm: serviceContext.llm,
});
}
async function createIndex(
serviceContext: ServiceContext,
embeddings: Embedding[],
) {
const embeddingResults = embeddings.map((config) => {
return new TextNode({ text: config.text, embedding: config.embedding });
});
const indexDict = new IndexDict();
for (const node of embeddingResults) {
indexDict.addNode(node);
}
const index = await VectorStoreIndex.init({
indexStruct: indexDict,
serviceContext: serviceContext,
});
index.vectorStore.add(embeddingResults);
if (!index.vectorStore.storesText) {
await index.docStore.addDocuments(embeddingResults, true);
}
await index.indexStore?.addIndexStruct(indexDict);
index.indexStruct = indexDict;
return index;
}
function createReadableStream(
stream: AsyncIterable<Response>,
chatHistory: ChatHistory,
) {
const it = stream[Symbol.asyncIterator]();
let responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
let aborted = false;
writer.closed.catch(() => {
// reader aborted the stream
aborted = true;
});
const encoder = new TextEncoder();
const onNext = async () => {
try {
const { value, done } = await it.next();
if (aborted) return;
if (!done) {
writer.write(
encoder.encode(`data: ${JSON.stringify(value.response)}\n\n`),
);
onNext();
} else {
writer.write(
`data: ${JSON.stringify({
done: true,
// get the optional message containing the chat summary
memoryMessage: chatHistory
.newMessages()
.filter((m) => m.role === "memory")
.at(0),
})}\n\n`,
);
writer.close();
}
} catch (error) {
console.error("[LlamaIndex]", error);
writer.write(
`data: ${JSON.stringify({
error: Locale.Chat.LLMError,
})}\n\n`,
);
writer.close();
}
};
onNext();
return responseStream.readable;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
message,
chatHistory: messages,
datasource,
config,
embeddings,
}: {
message: MessageContent;
chatHistory: ChatMessage[];
datasource: string | undefined;
config: LLMConfig;
embeddings: Embedding[] | undefined;
} = body;
if (!message || !messages || !config) {
return NextResponse.json(
{
error:
"message, chatHistory and config are required in the request body",
},
{ status: 400 },
);
}
const allowAllModels = JSON.parse(process.env.ALLOW_ALL_MODELS || "false");
if (!allowAllModels && config.model !== "gpt-3.5-turbo") {
return NextResponse.json(
{
error:
"Only configured to use GPT 3.5. Change model used by the bot or set 'ALLOW_ALL_MODELS' env variable to 'true'.",
},
{ status: 400 },
);
}
const llm = new OpenAI({
model: config.model,
temperature: config.temperature,
topP: config.topP,
maxTokens: config.maxTokens,
});
const serviceContext = serviceContextFromDefaults({
llm,
chunkSize: DATASOURCES_CHUNK_SIZE,
chunkOverlap: DATASOURCES_CHUNK_OVERLAP,
});
const chatEngine = await createChatEngine(
serviceContext,
datasource,
embeddings,
);
const chatHistory = config.sendMemory
? new SummaryChatHistory({ llm, messages })
: new SimpleChatHistory({ messages });
const stream = await chatEngine.chat({
message,
chatHistory,
stream: true,
});
const readableStream = createReadableStream(stream, chatHistory);
return new NextResponse(readableStream, {
headers: {
"Content-Type": "text/event-stream",
Connection: "keep-alive",
"Cache-Control": "no-cache, no-transform",
},
});
} catch (error) {
console.error("[LlamaIndex]", error);
return NextResponse.json(
{
error: Locale.Chat.LLMError,
},
{
status: 500,
},
);
}
}
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Set max running time of function, for Vercel Hobby use 10 seconds, see https://vercel.com/docs/functions/serverless-functions/runtimes#maxduration
export const maxDuration = 120;
-39
View File
@@ -1,39 +0,0 @@
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { URLDetail } from "../../client/fetch/url";
export async function POST(request: Request): Promise<NextResponse> {
const { searchParams } = new URL(request.url);
const filename = searchParams.get("filename");
if (!filename || !request.body) {
return NextResponse.json(
{ error: "Missing filename URL parameter or request body" },
{ status: 400 },
);
}
try {
const blob = await put(filename, request.body, {
access: "public",
});
const json = {
type: blob.contentType as URLDetail["type"],
url: blob.url,
// TODO: needs to return the size of the uploaded file
size: NaN,
};
return NextResponse.json<URLDetail>(json);
} catch (error) {
console.error("[Upload]", error);
return NextResponse.json(
{
error: (error as Error).message,
},
{
status: 500,
},
);
}
}
-21
View File
@@ -1,21 +0,0 @@
// To store AbortControllers per session
export const ChatControllerPool = {
controllers: {} as Record<string, AbortController>,
addController(sessionId: string, controller: AbortController) {
this.controllers[sessionId] = controller;
},
stop(sessionId: string) {
const controller = this.controllers[sessionId];
controller?.abort();
},
isRunning(sessionId: string) {
return this.controllers[sessionId] !== undefined;
},
remove(sessionId: string) {
delete this.controllers[sessionId];
},
};
-70
View File
@@ -1,70 +0,0 @@
import { URLDetailContent } from "./url";
import { FileWrap } from "../../utils/file";
import {
ALLOWED_IMAGE_EXTENSIONS,
IMAGE_TYPES,
ImageType,
} from "@/app/constant";
export async function getDetailContentFromFile(
file: FileWrap,
): Promise<URLDetailContent> {
if (file.extension === "pdf") return await getPDFFileDetail(file);
if (file.extension === "txt") return await getTextFileDetail(file);
if (ALLOWED_IMAGE_EXTENSIONS.includes(file.extension))
return await getImageFileDetail(file);
throw new Error("Not supported file type");
}
async function getPDFFileDetail(file: FileWrap): Promise<URLDetailContent> {
const fileDataUrl = await file.readData({ asURL: true });
const pdfBase64 = fileDataUrl.split(",")[1];
const response = await fetch("/api/fetch", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pdf: pdfBase64,
fileName: file.name,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
return data as URLDetailContent;
}
async function getTextFileDetail(file: FileWrap): Promise<URLDetailContent> {
const textContent = await file.readData();
const response = await fetch("/api/fetch", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: textContent,
fileName: file.name,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
return data as URLDetailContent;
}
async function getImageFileDetail(file: FileWrap) {
const response = await fetch(`/api/upload?filename=${file.name}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: file.file,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
console.log(data);
return data as URLDetailContent;
}
export const isImageFileType = (type: string) =>
IMAGE_TYPES.includes(type as ImageType);
-33
View File
@@ -1,33 +0,0 @@
import { DocumentType, ImageType } from "@/app/constant";
export type Embedding = {
text: string;
embedding: number[];
};
export type UrlDetailType = DocumentType | ImageType;
export type URLDetail = {
url: string;
size: number;
type: UrlDetailType;
embeddings?: Embedding[];
};
export type URLDetailContent = URLDetail & {
content?: string;
};
export const isURL = (text: string) => {
const isUrlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
return isUrlRegex.test(text);
};
export async function fetchSiteContent(
site: string,
): Promise<URLDetailContent> {
const response = await fetch(`/api/fetch?site=${site}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error);
return data as URLDetailContent;
}
-139
View File
@@ -1,139 +0,0 @@
import { REQUEST_TIMEOUT_MS } from "@/app/constant";
import { fetchEventSource } from "@fortaine/fetch-event-source";
import { Embedding } from "../fetch/url";
export const MESSAGE_ROLES = [
"system",
"user",
"assistant",
"URL",
"memory",
] as const;
export type MessageRole = (typeof MESSAGE_ROLES)[number];
export interface MessageContentDetail {
type: "text" | "image_url";
text: string;
image_url: { url: string };
}
export type MessageContent = string | MessageContentDetail[];
export interface RequestMessage {
role: MessageRole;
content: MessageContent;
}
export interface ResponseMessage {
role: MessageRole;
content: string;
}
export const ALL_MODELS = ["gpt-4-turbo", "gpt-3.5-turbo"] as const;
export type ModelType = (typeof ALL_MODELS)[number];
export interface LLMConfig {
model: ModelType;
temperature?: number;
topP?: number;
sendMemory?: boolean;
maxTokens?: number;
}
export interface ChatOptions {
message: MessageContent;
chatHistory: RequestMessage[];
config: LLMConfig;
datasource?: string;
embeddings?: Embedding[];
controller: AbortController;
onUpdate: (message: string) => void;
onFinish: (memoryMessage?: ResponseMessage) => void;
onError?: (err: Error) => void;
}
const CHAT_PATH = "/api/llm";
export function isVisionModel(model: ModelType) {
return model === "gpt-4-turbo";
}
export class LLMApi {
async chat(options: ChatOptions) {
const requestPayload = {
message: options.message,
chatHistory: options.chatHistory.map((m) => ({
role: m.role,
content: m.content,
})),
config: options.config,
datasource: options.datasource,
embeddings: options.embeddings,
};
console.log("[Request] payload: ", requestPayload);
const forceAbort = () => {
options.controller.signal.onabort = null;
options.controller.abort();
};
const requestTimeoutId = setTimeout(forceAbort, REQUEST_TIMEOUT_MS);
options.controller.signal.onabort = () => {
options.onFinish();
};
const handleError = (e: any) => {
clearTimeout(requestTimeoutId);
forceAbort();
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
};
try {
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: options.controller.signal,
headers: {
"Content-Type": "application/json",
},
};
let llmResponse = "";
await fetchEventSource(CHAT_PATH, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
if (!res.ok) {
const json = await res.json();
handleError(new Error(json.error));
}
},
onmessage(msg) {
try {
const json = JSON.parse(msg.data);
if (json.done) {
options.onFinish(json.memoryMessage);
} else if (json.error) {
handleError(new Error(json.error));
} else {
// received a new token
llmResponse += json;
options.onUpdate(llmResponse);
}
} catch (e) {
console.error("[Request] error parsing streaming delta", msg);
}
},
onerror: handleError,
openWhenHidden: true,
});
} catch (e) {
handleError(e);
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export default function BotOptions() {
return (
<Dialog>
<AlertDialog>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
+25 -47
View File
@@ -1,40 +1,24 @@
import { useBot } from "@/app/components/bot/use-bot";
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
import { useState } from "react";
import Locale from "../../../locales";
import { Card, CardContent } from "../../ui/card";
import { Checkbox } from "../../ui/checkbox";
import { Input } from "../../ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import ConfigItem from "./config-item";
import { BotAvatar, getEmojiUrl } from "@/app/components/ui/emoji";
import { AVAILABLE_DATASOURCES } from "@/app/store/bot";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/components/ui/select";
export default function BotConfig() {
const { bot, updateBot } = useBot();
const [showPicker, setShowPicker] = useState(false);
return (
<>
<div className="font-semibold mb-2">{Locale.Bot.Config.Title}</div>
<Card>
<CardContent className="divide-y p-5">
<ConfigItem title={Locale.Bot.Config.Avatar}>
<Popover open={showPicker}>
<PopoverTrigger onClick={() => setShowPicker(true)}>
<BotAvatar avatar={bot.avatar} />
</PopoverTrigger>
<PopoverContent align="end" className="w-fit">
<EmojiPicker
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}
onEmojiClick={(e) => {
updateBot((bot) => (bot.avatar = e.unified));
setShowPicker(false);
}}
/>
</PopoverContent>
</Popover>
</ConfigItem>
<ConfigItem title={Locale.Bot.Config.Name}>
<Input
type="text"
@@ -46,32 +30,26 @@ export default function BotConfig() {
}
/>
</ConfigItem>
<ConfigItem
title={Locale.Bot.Config.HideContext.Title}
subTitle={Locale.Bot.Config.HideContext.SubTitle}
>
<Checkbox
checked={bot.hideContext}
onCheckedChange={(checked) => {
<ConfigItem title={Locale.Bot.Config.Datasource}>
<Select
value={bot.datasource}
onValueChange={(value) => {
updateBot((bot) => {
bot.hideContext = Boolean(checked);
bot.datasource = value;
});
}}
/>
</ConfigItem>
<ConfigItem
title={Locale.Bot.Config.BotHello.Title}
subTitle={Locale.Bot.Config.BotHello.SubTitle}
>
<Input
type="text"
value={bot.botHello || ""}
onChange={(e) => {
updateBot((bot) => {
bot.botHello = e.currentTarget.value;
});
}}
/>
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select data source" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_DATASOURCES.map((datasource) => (
<SelectItem value={datasource} key={datasource}>
{datasource}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
</CardContent>
</Card>
@@ -8,28 +8,10 @@ import {
} from "@/app/components/ui/select";
import { Textarea } from "@/app/components/ui/textarea";
import { ArrowDownLeftSquare, PlusCircle, XCircle } from "lucide-react";
import { useQuery } from "react-query";
import { MESSAGE_ROLES } from "../../../client/platforms/llm";
import Locale from "../../../locales";
import { ChatMessage } from "../../../store";
import { fetchSiteContent, isURL } from "../../../client/fetch/url";
interface PromptInputStatusProps {
status: "loading" | "success" | "error";
detail: string;
}
const promptInputStatusStyle = {
loading: "text-yellow-500",
success: "text-primary",
error: "text-destructive",
};
function ContextPromptInputStatus(props: PromptInputStatusProps) {
return (
<div className={promptInputStatusStyle[props.status]}>{props.detail}</div>
);
}
import { Message as ChatMessage } from "ai";
import { v4 as uuidv4 } from "uuid";
import { MESSAGE_ROLES } from "@/app/store/bot";
function ContextPromptItem(props: {
index: number;
@@ -38,77 +20,15 @@ function ContextPromptItem(props: {
remove: () => void;
insert: () => void;
}) {
const requiredUrlInput = props.prompt.role === "URL";
const currentInputValue = props.prompt.urlDetail
? props.prompt.urlDetail.url
: props.prompt.content;
const invalidUrlInput =
!!currentInputValue && requiredUrlInput && !isURL(currentInputValue);
const isFetchContentSuccess = requiredUrlInput && !!props.prompt.urlDetail;
const { isLoading, error } = useQuery(
["content", currentInputValue],
() => fetchSiteContent(currentInputValue),
{
enabled: requiredUrlInput && isURL(currentInputValue),
refetchOnWindowFocus: false,
retry: false,
onSuccess: (urlDetail) => {
props.update({
...props.prompt,
content: urlDetail.content!,
urlDetail,
});
},
},
);
const handleUpdatePrompt = async (input: string) => {
props.update({
...props.prompt,
content: input,
urlDetail: undefined,
});
};
const getPromptInputStatus = (): PromptInputStatusProps | undefined => {
if (invalidUrlInput) {
return {
status: "error",
detail: "Please enter a valid URL",
};
}
const errorMsg = (error as any)?.message;
if (errorMsg) {
return {
status: "error",
detail: errorMsg,
};
}
if (isLoading) {
return {
status: "loading",
detail: "Fetching site content...",
};
}
if (isFetchContentSuccess) {
return {
status: "success",
detail: "The URL has been successfully retrieved.",
};
}
return undefined;
};
const promptInputStatus = getPromptInputStatus();
return (
<>
{promptInputStatus && <ContextPromptInputStatus {...promptInputStatus} />}
<div className="flex justify-center gap-2 w-full group items-start py-2">
<div className="flex gap-2 items-center">
<Select
@@ -134,7 +54,7 @@ function ContextPromptItem(props: {
</div>
<Textarea
value={currentInputValue}
value={props.prompt.content}
className={
"flex-1 max-w-full text-left min-h-0 ring-inset focus-visible:ring-offset-0"
}
@@ -179,13 +99,14 @@ export function ContextPrompts(props: {
props.updateContext((context) => context.splice(i, 0, prompt));
};
const createNewEmptyPrompt = () => {
const createNewPrompt = (index = props.context.length) => {
addContextPrompt(
{
role: "user",
content: "",
id: uuidv4(),
},
props.context.length,
index,
);
};
@@ -202,7 +123,7 @@ export function ContextPrompts(props: {
<div className="mb-5">
<div className="font-semibold mb-2 flex items-center justify-between">
<span>{Locale.Context.Title}</span>
<Button variant="secondary" onClick={createNewEmptyPrompt}>
<Button variant="secondary" onClick={() => createNewPrompt()}>
<PlusCircle className="mr-2 h-4 w-4" /> {Locale.Context.Add}
</Button>
</div>
@@ -213,15 +134,7 @@ export function ContextPrompts(props: {
prompt={c}
update={(prompt) => updateContextPrompt(i, prompt)}
remove={() => removeContextPrompt(i)}
insert={() => {
addContextPrompt(
{
role: "user",
content: "",
},
i + 1,
);
}}
insert={() => createNewPrompt(i + 1)}
/>
</div>
))}
+1 -1
View File
@@ -3,7 +3,7 @@ import { useBot } from "@/app/components/bot/use-bot";
import BotConfig from "./bot-config";
import { ModelConfigList } from "./model-config";
import { Separator } from "@/app/components/ui/separator";
import { LLMConfig } from "@/app/client/platforms/llm";
import { LLMConfig } from "@/app/store/bot";
export default function BotSettings(props: { extraConfigs?: JSX.Element }) {
const { bot, updateBot } = useBot();
@@ -10,11 +10,7 @@ import {
import Locale from "../../../locales";
import { Card, CardContent } from "../../ui/card";
import ConfigItem from "./config-item";
import {
ALL_MODELS,
ModelType,
LLMConfig,
} from "../../../client/platforms/llm";
import { ALL_MODELS, ModelType, LLMConfig } from "@/app/store/bot";
function limitNumber(
x: number,
+2 -1
View File
@@ -3,7 +3,8 @@ import { useNavigate } from "react-router-dom";
import { Path } from "../../constant";
import { Bot, useBotStore } from "../../store/bot";
import { useSidebarContext } from "../home";
import { Updater } from "@/app/typing";
type Updater<T> = (updater: (value: T) => void) => void;
const BotItemContext = createContext<{
bot: Bot;
-50
View File
@@ -1,50 +0,0 @@
import { Button } from "@/app/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/components/ui/tooltip";
export function ChatAction(props: {
text: string;
icon: JSX.Element;
onClick: () => void;
showTitle?: boolean;
buttonVariant?: "ghost" | "outline";
}) {
const { text, icon, onClick, showTitle, buttonVariant } = props;
const buttonVariantDefault = "ghost";
const variant = buttonVariant || buttonVariantDefault;
if (!showTitle) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="px-1">
<Button
size="icon"
variant={variant}
className="group"
onClick={onClick}
>
{icon}
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs text-muted-foreground">
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<Button size="sm" variant={variant} className="group" onClick={onClick}>
{icon}
<div className="text-xs text-muted-foreground ml-2">{text}</div>
</Button>
);
}
+3 -4
View File
@@ -13,10 +13,9 @@ export default function ChatHeader() {
const botStore = useBotStore();
const bot = botStore.currentBot();
const session = botStore.currentSession();
const numberOfMessages =
(bot.botHello?.length ? 1 : 0) + session.messages.length;
const numberOfMessages = session.messages.length;
return (
<div className="relative">
<div className="relative shadow-md rounded-xl shrink-0">
<div className="absolute top-4 left-5">
{isMobileScreen && (
<Button
@@ -29,7 +28,7 @@ export default function ChatHeader() {
</Button>
)}
</div>
<div className="text-center py-4">
<div className="text-center py-2">
<Typography.H4>{bot.name}</Typography.H4>
<div className="text-sm text-muted-foreground">
{Locale.Chat.SubTitle(numberOfMessages)}
-261
View File
@@ -1,261 +0,0 @@
import {
getDetailContentFromFile,
isImageFileType,
} from "@/app/client/fetch/file";
import { URLDetail, URLDetailContent, isURL } from "@/app/client/fetch/url";
import { Button } from "@/app/components/ui/button";
import { Textarea } from "@/app/components/ui/textarea";
import { useToast } from "@/app/components/ui/use-toast";
import { useSubmitHandler } from "@/app/hooks/useSubmit";
import { cn } from "@/app/lib/utils";
import { useBotStore } from "@/app/store/bot";
import { FileWrap } from "@/app/utils/file";
import { Send } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { ChatControllerPool } from "../../client/controller";
import {
ALLOWED_DOCUMENT_EXTENSIONS,
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_TEXT_EXTENSIONS,
DOCUMENT_FILE_SIZE_LIMIT,
} from "../../constant";
import Locale from "../../locales";
import { callSession } from "../../store";
import { autoGrowTextArea } from "../../utils/autogrow";
import { useMobileScreen } from "../../utils/mobile";
import FileUploader from "../ui/file-uploader";
import ImagePreview from "../ui/image-preview";
import { isVisionModel } from "../../client/platforms/llm";
export interface ChatInputProps {
inputRef: React.RefObject<HTMLTextAreaElement>;
userInput: string;
temporaryURLInput: string;
setUserInput: (input: string) => void;
setTemporaryURLInput: (url: string) => void;
scrollToBottom: () => void;
setAutoScroll: (autoScroll: boolean) => void;
}
export default function ChatInput(props: ChatInputProps) {
const {
inputRef,
userInput,
setUserInput,
setTemporaryURLInput,
scrollToBottom,
setAutoScroll,
} = props;
const { toast } = useToast();
const { shouldSubmit } = useSubmitHandler();
const isMobileScreen = useMobileScreen();
const botStore = useBotStore();
const bot = botStore.currentBot();
const session = botStore.currentSession();
const [imageFile, setImageFile] = useState<URLDetail>();
const [temporaryBlobUrl, setTemporaryBlobUrl] = useState<string>();
// auto grow input
const [inputRows, setInputRows] = useState(2);
const measure = useDebouncedCallback(
() => {
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
const inputRows = Math.min(
20,
Math.max(1 + Number(!isMobileScreen), rows),
);
setInputRows(inputRows);
},
100,
{
leading: true,
trailing: true,
},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
const onInput = (text: string) => {
setUserInput(text);
};
const showError = (errMsg: string) => {
toast({
title: errMsg,
variant: "destructive",
});
};
const callLLM = async ({
input,
fileDetail,
}: {
input?: string;
fileDetail?: URLDetailContent;
}) => {
await callSession(
bot,
session,
{
onUpdateMessages: (messages) => {
botStore.updateBotSession((session) => {
// trigger re-render of messages
session.messages = messages;
}, bot.id);
},
},
input,
fileDetail,
);
setImageFile(undefined);
setTemporaryURLInput("");
};
const manageTemporaryBlobUrl = (
file: File,
action: () => Promise<void>,
): Promise<void> => {
let tempUrl: string;
if (isImageFileType(file.type)) {
tempUrl = URL.createObjectURL(file);
setTemporaryBlobUrl(tempUrl);
}
return action().finally(() => {
if (isImageFileType(file.type)) {
URL.revokeObjectURL(tempUrl);
setTemporaryBlobUrl(undefined);
}
});
};
const doSubmitFile = async (fileInput: FileWrap) => {
try {
await manageTemporaryBlobUrl(fileInput.file, async () => {
const fileDetail = await getDetailContentFromFile(fileInput);
if (isImageFileType(fileInput.file.type)) {
setImageFile(fileDetail);
} else {
callLLM({ fileDetail });
}
});
} catch (error) {
showError(Locale.Upload.Failed((error as Error).message));
}
};
const doSubmit = async (input: string) => {
if (input.trim() === "") return;
if (isURL(input)) {
setTemporaryURLInput(input);
}
setUserInput("");
await callLLM({ input, fileDetail: imageFile });
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (shouldSubmit(e)) {
if (!isRunning && !isUploadingImage) {
doSubmit(userInput);
}
e.preventDefault();
}
};
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
const isRunning = ChatControllerPool.isRunning(bot.id);
const removeImage = () => {
setImageFile(undefined);
};
const previewImage = temporaryBlobUrl || imageFile?.url;
const isUploadingImage = temporaryBlobUrl !== undefined;
const checkExtension = (extension: string) => {
if (!ALLOWED_DOCUMENT_EXTENSIONS.includes(extension)) {
return Locale.Upload.Invalid(ALLOWED_DOCUMENT_EXTENSIONS.join(","));
}
if (
!isVisionModel(bot.modelConfig.model) &&
ALLOWED_IMAGE_EXTENSIONS.includes(extension)
) {
return Locale.Upload.ModelDoesNotSupportImages(
ALLOWED_TEXT_EXTENSIONS.join(","),
);
}
return null;
};
return (
<div className="flex flex-1 items-end relative">
{previewImage && (
<div className="absolute top-[12px] left-[12px] w-[50px] h-[50px] rounded-xl cursor-pointer">
<ImagePreview
url={previewImage}
uploading={isUploadingImage}
onRemove={removeImage}
/>
</div>
)}
<Textarea
className={cn(
"ring-inset focus-visible:ring-offset-0 pr-28 md:pr-40 min-h-[56px]",
{
"pt-20": previewImage,
},
)}
ref={inputRef}
placeholder={
isMobileScreen ? Locale.Chat.InputMobile : Locale.Chat.Input
}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
rows={inputRows}
autoFocus={autoFocus}
/>
<div className="my-2 flex items-center gap-2.5 absolute right-[15px]">
<FileUploader
config={{
inputId: "document-uploader",
allowedExtensions: ALLOWED_DOCUMENT_EXTENSIONS,
checkExtension,
fileSizeLimit: DOCUMENT_FILE_SIZE_LIMIT,
disabled: isRunning || isUploadingImage,
}}
onUpload={doSubmitFile}
onError={showError}
/>
{isMobileScreen ? (
<Button
size="icon"
onClick={() => doSubmit(userInput)}
disabled={isRunning || isUploadingImage}
>
<Send className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => doSubmit(userInput)}
disabled={isRunning || isUploadingImage}
>
<Send className="h-4 w-4 mr-2" />
{Locale.Chat.Send}
</Button>
)}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useChatSession } from "./useChatSession";
import { ChatInput, ChatMessages } from "@/cl/app/components/ui/chat";
// Custom ChatSection for ChatLlamaindex
export default function ChatSection() {
const {
messages,
input,
isLoading,
handleSubmit,
handleInputChange,
reload,
stop,
append,
setInput,
} = useChatSession();
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}
/>
</div>
);
}
-362
View File
@@ -1,362 +0,0 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/app/components/ui/hover-card";
import { Loading } from "@/app/components/ui/loading";
import { ScrollArea } from "@/app/components/ui/scroll-area";
import { useToast } from "@/app/components/ui/use-toast";
import { useScrollToBottom } from "@/app/hooks/useScroll";
import { cn } from "@/app/lib/utils";
import { useBotStore } from "@/app/store/bot";
import { copyToClipboard } from "@/app/utils/clipboard";
import { Clipboard, Eraser, PauseCircle, Trash } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChatControllerPool } from "../../client/controller";
import { CHAT_PAGE_SIZE, REQUEST_TIMEOUT_MS } from "../../constant";
import Locale from "../../locales";
import { ChatMessage, createMessage } from "../../store";
import { prettyObject } from "../../utils/format";
import { useMobileScreen } from "../../utils/mobile";
import { Separator } from "../ui/separator";
import { ChatAction } from "./chat-action";
import ChatHeader from "./chat-header";
import ChatInput from "./chat-input";
import { ClearContextDivider } from "./clear-context-divider";
import { isImageFileType } from "@/app/client/fetch/file";
const Markdown = dynamic(
async () => (await import("../ui/markdown")).Markdown,
{
loading: () => <Loading />,
},
);
export function Chat() {
const { toast } = useToast();
const isMobileScreen = useMobileScreen();
const botStore = useBotStore();
const bot = botStore.currentBot();
const session = botStore.currentSession();
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [temporaryURLInput, setTemporaryURLInput] = useState("");
const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
useEffect(() => {
botStore.updateBotSession((session) => {
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
session.messages.forEach((m) => {
// check if should stop all stale messages
if (m.isError || (m.date && new Date(m.date).getTime() < stopTiming)) {
if (m.streaming) {
m.streaming = false;
}
if (m.content.length === 0) {
m.isError = true;
m.content = prettyObject({
error: true,
message: "empty response",
});
}
}
});
}, bot.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const deleteMessage = (msgId?: string) => {
botStore.updateBotSession(
(session) =>
(session.messages = session.messages.filter((m) => m.id !== msgId)),
bot.id,
);
};
const onDelete = (msgId: string) => {
deleteMessage(msgId);
};
const context: ChatMessage[] = useMemo(() => {
return bot.hideContext ? [] : bot.context.slice();
}, [bot.context, bot.hideContext]);
const getUrlTypePrefix = (type: string) => {
if (type === "text/html") return "HTML";
if (type === "application/pdf") return "PDF";
if (type === "text/plain") return "TXT";
return Locale.Upload.UnknownFileType;
};
// preview messages
const renderMessages = useMemo(() => {
const getFrontendMessages = (messages: ChatMessage[]) => {
return messages.map((message) => {
if (!message.urlDetail || isImageFileType(message.urlDetail.type))
return message;
const urlTypePrefix = getUrlTypePrefix(message.urlDetail.type);
const sizeInKB = Math.round(message.urlDetail.size / 1024);
return {
...message,
content: `${message.urlDetail.url}\n\`${urlTypePrefix}${sizeInKB} KB\``,
};
});
};
const getUrlPreviewMessage = () => {
const lastMessage = session.messages[session.messages.length - 1];
const showPreviewUrl = temporaryURLInput && !lastMessage?.streaming;
let previewUrlMessage: ChatMessage | undefined;
if (showPreviewUrl) {
previewUrlMessage = createMessage({
role: "user",
content: `${temporaryURLInput}\n\`${Locale.Chat.LoadingURL}\``,
});
}
return previewUrlMessage;
};
return context
.concat(
bot.botHello
? [
createMessage({
role: "assistant",
content: bot.botHello,
}),
]
: [],
)
.concat(getFrontendMessages(session.messages))
.concat(getUrlPreviewMessage() || []);
}, [session.messages, bot.botHello, temporaryURLInput, context]);
const [msgRenderIndex, _setMsgRenderIndex] = useState(
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
);
function setMsgRenderIndex(newIndex: number) {
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
newIndex = Math.max(0, newIndex);
_setMsgRenderIndex(newIndex);
}
const messages = useMemo(() => {
const endRenderIndex = Math.min(
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
renderMessages.length,
);
return renderMessages.slice(msgRenderIndex, endRenderIndex);
}, [msgRenderIndex, renderMessages]);
const onChatBodyScroll = (e: HTMLElement) => {
const bottomHeight = e.scrollTop + e.clientHeight;
const edgeThreshold = e.clientHeight;
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
const isHitBottom = bottomHeight >= e.scrollHeight - 10;
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
if (isTouchTopEdge && !isTouchBottomEdge) {
setMsgRenderIndex(prevPageMsgIndex);
} else if (isTouchBottomEdge) {
setMsgRenderIndex(nextPageMsgIndex);
}
setAutoScroll(isHitBottom);
};
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
}
// clear context index = context length + index in messages
const clearContextIndex =
(session.clearContextIndex ?? -1) >= 0
? session.clearContextIndex! +
context.length +
(bot.botHello ? 1 : 0) -
msgRenderIndex
: -1;
const clearContext = () => {
botStore.updateBotSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
}
}, bot.id);
};
const stop = () => ChatControllerPool.stop(bot.id);
const isRunning = ChatControllerPool.isRunning(bot.id);
return (
<div className="flex flex-col relative h-full" key={bot.id}>
<ChatHeader />
<ScrollArea
className="flex-1 overflow-auto overflow-x-hidden relative overscroll-none pb-10 p-5"
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
}}
>
<div className="space-y-5">
{messages.map((message, i) => {
const isUser = message.role === "user";
const isMemory = message.role === "memory";
const isContext = i < context.length;
const showActions =
i > 0 && !(message.content.length === 0) && !isContext;
const showThinking = message.streaming;
const shouldShowClearContextDivider = i === clearContextIndex - 1;
return (
<div className="space-y-5" key={i}>
<div
className={
isUser
? "flex flex-row-reverse"
: "flex flex-row last:animate-[slide-in_ease_0.3s]"
}
>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<div
className={cn(
"max-w-[80%] flex flex-col items-start",
isUser && "items-end",
)}
>
{showThinking && (
<div
className={
"text-xs text-[#aaa] leading-normal my-1"
}
>
{Locale.Chat.Thinking}
</div>
)}
<div
className={cn(
"box-border max-w-full text-sm select-text relative break-words rounded-lg px-3 py-2",
isUser
? "ml-auto bg-primary text-primary-foreground"
: isMemory
? "italic text-secondary-foreground"
: "bg-muted",
)}
>
{message.urlDetail?.type &&
isImageFileType(message.urlDetail.type) && (
<img
src={message.urlDetail.url}
alt="Message image"
className="object-contain w-full h-52 rounded-lg mb-2"
/>
)}
<Markdown
content={message.content}
loading={
message.streaming &&
message.content.length === 0 &&
!isUser
}
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput(message.content);
}}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
/>
</div>
<div className="text-xs text-muted-foreground opacity-80 whitespace-nowrap text-right w-full box-border pointer-events-none z-[1]">
{isContext
? Locale.Chat.IsContext
: message.date?.toLocaleString()}
</div>
</div>
</HoverCardTrigger>
{showActions && (
<HoverCardContent
side="top"
align={isUser ? "end" : "start"}
className="py-1 px-0 w-fit"
>
<div className="flex items-center divide-x">
{!message.streaming && (
<>
{message.id && (
<ChatAction
text={Locale.Chat.Actions.Delete}
icon={<Trash className="w-4 h-4" />}
onClick={() => onDelete(message.id!)}
/>
)}
<ChatAction
text={Locale.Chat.Actions.Copy}
icon={<Clipboard className="w-4 h-4" />}
onClick={() =>
copyToClipboard(message.content, toast)
}
/>
</>
)}
</div>
</HoverCardContent>
)}
</HoverCard>
</div>
{shouldShowClearContextDivider && (
<ClearContextDivider botId={bot.id} />
)}
</div>
);
})}
</div>
</ScrollArea>
<Separator />
<div className="relative w-full box-border flex-col pt-2.5 p-5 space-y-2">
<div className="flex justify-between items-center">
<ChatAction
text={Locale.Chat.InputActions.Clear}
icon={<Eraser className="w-4 h-4" />}
onClick={clearContext}
showTitle
buttonVariant="outline"
/>
{isRunning && (
<ChatAction
onClick={stop}
text={Locale.Chat.InputActions.Stop}
icon={<PauseCircle className="w-4 h-4" />}
showTitle
buttonVariant="outline"
/>
)}
</div>
<ChatInput
inputRef={inputRef}
userInput={userInput}
temporaryURLInput={temporaryURLInput}
setUserInput={setUserInput}
setTemporaryURLInput={setTemporaryURLInput}
scrollToBottom={scrollToBottom}
setAutoScroll={setAutoScroll}
/>
</div>
</div>
);
}
@@ -1,30 +0,0 @@
import { useBotStore } from "@/app/store/bot";
import Locale from "../../locales";
import { Card, CardContent } from "@/app/components/ui/card";
export function ClearContextDivider({ botId }: { botId: string }) {
const botStore = useBotStore();
return (
<Card
className="cursor-pointer hover:border-primary rounded-sm"
onClick={() =>
botStore.updateBotSession(
(session) => (session.clearContextIndex = undefined),
botId,
)
}
>
<CardContent className="p-1 group text-foreground hover:text-primary">
<div className="text-center text-xs font-semibold">
<span className="inline-block group-hover:hidden opacity-50">
{Locale.Context.Clear}
</span>
<span className="hidden group-hover:inline-block">
{Locale.Context.Revert}
</span>
</div>
</CardContent>
</Card>
);
}
+13
View File
@@ -0,0 +1,13 @@
import ChatSection from "./chat-session";
import ChatHeader from "./chat-header";
export default function ChatPage() {
return (
<div className="flex flex-col gap-4 h-full w-full p-4">
<ChatHeader />
<div className="flex-1 overflow-auto shadow-xl">
<ChatSection />
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useBotStore } from "@/app/store/bot";
import { useChat } from "ai/react";
import { useCallback, useEffect, useState } from "react";
import { useClientConfig } from "@/cl/app/components/ui/chat/hooks/use-config";
// Combine useChat and useBotStore to manage chat session
export function useChatSession() {
const botStore = useBotStore();
const bot = botStore.currentBot();
const session = botStore.currentSession();
const { updateBotSession } = botStore;
const [isFinished, setIsFinished] = useState(false);
const { chatAPI } = useClientConfig();
const {
messages,
setMessages,
input,
isLoading,
handleSubmit,
handleInputChange,
reload,
stop,
append,
setInput,
} = useChat({
api: chatAPI,
headers: {
"Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26
},
body: {
context: bot.context,
modelConfig: bot.modelConfig,
datasource: bot.datasource,
},
onError: (error: unknown) => {
if (!(error instanceof Error)) throw error;
const message = JSON.parse(error.message);
alert(message.detail);
},
onFinish: () => setIsFinished(true),
});
// load chat history from session when component mounts
const loadChatHistory = useCallback(() => {
setMessages(session.messages);
}, [session, setMessages]);
// sync chat history with bot session when finishing streaming
const syncChatHistory = useCallback(() => {
if (messages.length === 0) return;
updateBotSession((session) => (session.messages = messages), bot.id);
}, [messages, updateBotSession, bot.id]);
useEffect(() => {
loadChatHistory();
}, [loadChatHistory]);
useEffect(() => {
if (isFinished) {
syncChatHistory();
setIsFinished(false);
}
}, [isFinished, setIsFinished, syncChatHistory]);
return {
messages,
input,
isLoading,
handleSubmit,
handleInputChange,
reload,
stop,
append,
setInput,
};
}
+1 -1
View File
@@ -26,7 +26,7 @@ const SettingsPage = dynamic(
},
);
const ChatPage = dynamic(async () => (await import("./chat/chat")).Chat, {
const ChatPage = dynamic(async () => (await import("./chat/index")).default, {
loading: () => <LoadingPage />,
});
+2 -2
View File
@@ -17,8 +17,8 @@ export function SideBar(props: { className?: string }) {
const { setShowSidebar } = useSidebarContext();
return (
<div className="h-full relative group border-r w-full md:w-[300px]">
<div className="w-full h-full p-5 flex flex-col gap-5">
<div className="h-full relative group w-full md:w-[360px] p-4">
<div className="w-full h-full p-5 flex flex-col gap-5 shadow-2xl rounded-xl">
<div className="flex flex-col flex-1">
<div className="mb-5 flex justify-between gap-5 items-start">
<div>
-100
View File
@@ -1,100 +0,0 @@
import { buttonVariants } from "@/app/components/ui/button";
import { cn } from "@/app/lib/utils";
import { FileWrap } from "@/app/utils/file";
import { ChangeEvent, useState } from "react";
import Locale from "../../locales";
import { Paperclip, Loader2 } from "lucide-react";
export interface FileUploaderProps {
config?: {
inputId?: string;
fileSizeLimit?: number;
allowedExtensions?: string[];
checkExtension?: (extension: string) => string | null;
disabled: boolean;
};
onUpload: (file: FileWrap) => Promise<void>;
onError: (errMsg: string) => void;
}
const DEFAULT_INPUT_ID = "fileInput";
const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50; // 50 MB
export default function FileUploader({
config,
onUpload,
onError,
}: 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 Locale.Upload.Invalid(allowedExtensions!.join(","));
}
return null;
};
const checkExtension = config?.checkExtension ?? defaultCheckExtension;
const isFileSizeExceeded = (file: FileWrap) => {
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);
const fileWrap = new FileWrap(file);
await handleUpload(fileWrap);
resetInput();
setUploading(false);
};
const handleUpload = async (file: FileWrap) => {
const extensionError = checkExtension(file.extension);
if (extensionError) {
return onError(extensionError);
}
if (isFileSizeExceeded(file)) {
return onError(Locale.Upload.SizeExceeded(fileSizeLimit / 1024 / 1024));
}
await onUpload(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>
);
}
+1 -30
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
export const GITHUB_URL = "https://github.com/run-llama/chat-llamaindex";
export enum Path {
@@ -10,33 +11,3 @@ export enum Path {
export enum FileName {
Bots = "bots.json",
}
export const REQUEST_TIMEOUT_MS = 60000;
export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45;
export const ALLOWED_IMAGE_EXTENSIONS = ["jpeg", "jpg", "png", "gif", "webp"];
export const ALLOWED_TEXT_EXTENSIONS = ["pdf", "txt"];
export const ALLOWED_DOCUMENT_EXTENSIONS = [
...ALLOWED_TEXT_EXTENSIONS,
...ALLOWED_IMAGE_EXTENSIONS,
];
export const DOCUMENT_FILE_SIZE_LIMIT = 1024 * 1024 * 10; // 10 MB
export const DOCUMENT_TYPES = [
"text/html",
"application/pdf",
"text/plain",
] as const;
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
export const IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
] as const;
export type ImageType = (typeof IMAGE_TYPES)[number];
-31
View File
@@ -1,31 +0,0 @@
import { useEffect, useRef, useState } from "react";
export function useScrollToBottom() {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
function scrollDomToBottom() {
const dom = scrollRef.current;
if (dom) {
requestAnimationFrame(() => {
setAutoScroll(true);
dom.scrollTo(0, dom.scrollHeight);
});
}
}
// auto scroll
useEffect(() => {
if (autoScroll) {
scrollDomToBottom();
}
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollDomToBottom,
};
}
-33
View File
@@ -1,33 +0,0 @@
import { useRef, useEffect } from "react";
export function useSubmitHandler() {
const isComposing = useRef(false);
useEffect(() => {
const onCompositionStart = () => {
isComposing.current = true;
};
const onCompositionEnd = () => {
isComposing.current = false;
};
window.addEventListener("compositionstart", onCompositionStart);
window.addEventListener("compositionend", onCompositionEnd);
return () => {
window.removeEventListener("compositionstart", onCompositionStart);
window.removeEventListener("compositionend", onCompositionEnd);
};
}, []);
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return false;
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
return false;
return !e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey;
};
return {
shouldSubmit,
};
}
+11 -10
View File
@@ -3,28 +3,29 @@ import "./styles/lib/markdown.css";
import "./styles/lib/highlight.css";
import Locale from "./locales";
import { type Metadata } from "next";
import { Viewport, type Metadata } from "next";
import { Toaster } from "@/app/components/ui/toaster";
import { ThemeProvider } from "@/app/components/layout/theme-provider";
export const metadata: Metadata = {
title: Locale.Welcome.Title,
description: Locale.Welcome.SubTitle,
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
},
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
appleWebApp: {
title: Locale.Welcome.Title,
statusBarStyle: "default",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: {
+1 -9
View File
@@ -122,17 +122,9 @@ const en = {
Clone: "Clone",
},
Config: {
Avatar: "Bot Avatar",
Name: "Bot Name",
HideContext: {
Title: "Hide Context Prompts",
SubTitle: "Do not show in-context prompts in chat",
},
BotHello: {
Title: "Welcome Message",
SubTitle: "Welcome message sent when starting a new chat",
},
Title: "Bot Settings",
Datasource: "Data Source",
},
},
+11 -9
View File
@@ -1,8 +1,6 @@
import { Bot } from "@/app/store/bot";
import { Bot, ChatSession } from "@/app/store/bot";
import { nanoid } from "nanoid";
import Locale from "../locales";
import { ModelType } from "@/app/client/platforms/llm";
import { createEmptySession } from "../store";
const TEMPLATE = (PERSONA: string) =>
`I want you to act as a ${PERSONA}. I will provide you with the context needed to solve my problem. Use intelligent, simple, and understandable language. Be concise. It is helpful to explain your thoughts step by step and with bullet points.`;
@@ -23,7 +21,6 @@ export const DEMO_BOTS: DemoBot[] = [
sendMemory: false,
},
readOnly: true,
hideContext: false,
},
{
id: "3",
@@ -34,6 +31,7 @@ export const DEMO_BOTS: DemoBot[] = [
{
role: "system",
content: TEMPLATE("Red Hat Linux Expert"),
id: "demo-bot-3-system-message",
},
],
modelConfig: {
@@ -44,7 +42,6 @@ export const DEMO_BOTS: DemoBot[] = [
},
readOnly: true,
datasource: "redhat",
hideContext: false,
},
{
id: "4",
@@ -55,6 +52,7 @@ export const DEMO_BOTS: DemoBot[] = [
{
role: "system",
content: TEMPLATE("Apple Genius specialized in Apple Watches"),
id: "demo-bot-4-system-message",
},
],
modelConfig: {
@@ -65,7 +63,6 @@ export const DEMO_BOTS: DemoBot[] = [
},
readOnly: true,
datasource: "watchos",
hideContext: false,
},
{
id: "5",
@@ -76,6 +73,7 @@ export const DEMO_BOTS: DemoBot[] = [
{
role: "system",
content: TEMPLATE("Lawyer specialized in the basic law of Germany"),
id: "demo-bot-5-system-message",
},
],
modelConfig: {
@@ -86,7 +84,6 @@ export const DEMO_BOTS: DemoBot[] = [
},
readOnly: true,
datasource: "basic_law_germany",
hideContext: false,
},
];
@@ -106,7 +103,7 @@ export const createEmptyBot = (): Bot => ({
name: Locale.Store.DefaultBotName,
context: [],
modelConfig: {
model: "gpt-4-1106-preview" as ModelType,
model: "gpt-3.5-turbo",
temperature: 0.5,
maxTokens: 4096,
sendMemory: false,
@@ -114,6 +111,11 @@ export const createEmptyBot = (): Bot => ({
readOnly: false,
createdAt: Date.now(),
botHello: Locale.Store.BotHello,
hideContext: false,
session: createEmptySession(),
});
export function createEmptySession(): ChatSession {
return {
messages: [],
};
}
+39 -5
View File
@@ -1,9 +1,44 @@
import { nanoid } from "nanoid";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { LLMConfig } from "../client/platforms/llm";
import { ChatSession, ChatMessage, createEmptySession } from "./session";
import { DEMO_BOTS, createDemoBots, createEmptyBot } from "@/app/bots/bot.data";
import {
DEMO_BOTS,
createDemoBots,
createEmptyBot,
createEmptySession,
} from "./bot.data";
import { Message } from "ai";
export const MESSAGE_ROLES: Message["role"][] = [
"system",
"user",
"assistant",
"function",
"data",
"tool",
];
export const ALL_MODELS = ["gpt-3.5-turbo", "gpt-4-turbo", "gpt-4o"] as const;
export const AVAILABLE_DATASOURCES = [
"redhat",
"watchos",
"basic_law_germany",
] as const;
export type ModelType = (typeof ALL_MODELS)[number];
export interface LLMConfig {
model: ModelType;
temperature?: number;
topP?: number;
sendMemory?: boolean;
maxTokens?: number;
}
export interface ChatSession {
messages: Message[];
}
export type Share = {
id: string;
@@ -13,8 +48,7 @@ export type Bot = {
id: string;
avatar: string;
name: string;
hideContext: boolean;
context: ChatMessage[];
context: Message[];
modelConfig: LLMConfig;
readOnly: boolean;
botHello: string | null;
-1
View File
@@ -1 +0,0 @@
export * from "./session";
-237
View File
@@ -1,237 +0,0 @@
import { nanoid } from "nanoid";
import { ChatControllerPool } from "../client/controller";
import {
Embedding,
URLDetail,
URLDetailContent,
fetchSiteContent,
isURL,
} from "../client/fetch/url";
import {
MessageContentDetail,
LLMApi,
RequestMessage,
MessageRole,
ResponseMessage,
} from "../client/platforms/llm";
import { prettyObject } from "../utils/format";
import { Bot } from "./bot";
import { isImageFileType } from "../client/fetch/file";
export type ChatMessage = {
role: MessageRole;
content: string;
date?: string;
streaming?: boolean;
isError?: boolean;
id?: string;
urlDetail?: URLDetail;
};
export function createMessage(override: Partial<ChatMessage>): ChatMessage {
return {
id: nanoid(),
date: new Date().toLocaleString(),
role: "user",
content: "",
...override,
};
}
export interface ChatSession {
messages: ChatMessage[];
clearContextIndex?: number;
}
export function createEmptySession(): ChatSession {
return {
messages: [],
};
}
async function createTextInputMessage(
content: string,
urlDetail?: URLDetailContent,
): Promise<ChatMessage> {
if (isURL(content)) {
const urlDetail = await fetchSiteContent(content);
return createFileInputMessage(urlDetail);
} else {
return createMessage({
role: "user",
content,
urlDetail,
});
}
}
async function createFileInputMessage(
fileDetail: URLDetailContent,
): Promise<ChatMessage> {
console.log("[User Input] did get file detail: ", fileDetail);
delete fileDetail["content"]; // clean content in file detail as we are only going to use its embeddings
return createMessage({
role: "user",
urlDetail: fileDetail,
});
}
function transformAssistantMessageForSending(
message: ChatMessage,
): RequestMessage {
const { content } = message;
// messages with role URL are assistant messages that contain a URL - the content is already retrieved by context-prompt.tsx
if (message.role !== "URL") return message;
return {
role: "assistant",
content,
};
}
async function createUserMessage(
content?: string,
urlDetail?: URLDetailContent,
): Promise<ChatMessage> {
let userMessage: ChatMessage;
if (content) {
userMessage = await createTextInputMessage(content, urlDetail);
} else if (urlDetail) {
userMessage = await createFileInputMessage(urlDetail);
} else {
throw new Error("Invalid user message");
}
return userMessage;
}
export async function callSession(
bot: Bot,
session: ChatSession,
callbacks: {
onUpdateMessages: (messages: ChatMessage[]) => void;
},
content?: string,
fileDetail?: URLDetailContent,
): Promise<void> {
const modelConfig = bot.modelConfig;
let userMessage: ChatMessage;
try {
userMessage = await createUserMessage(content, fileDetail);
} catch (error: any) {
// an error occurred when creating user message, show error message as bot message and don't call API
const userMessage = createMessage({
role: "user",
content,
});
const botMessage = createMessage({
role: "assistant",
content: prettyObject({
error: true,
message: error.message || "Invalid user message",
}),
});
// updating the session will trigger a re-render, so it will display the messages
session.messages = session.messages.concat([userMessage, botMessage]);
callbacks.onUpdateMessages(session.messages);
return;
}
const botMessage: ChatMessage = createMessage({
role: "assistant",
streaming: true,
});
const contextPrompts = bot.context.slice();
// get messages starting from the last clear context index (or all messages if there is no clear context index)
const recentMessages = !session.clearContextIndex
? session.messages
: session.messages.slice(session.clearContextIndex);
let sendMessages = [
...contextPrompts,
...recentMessages.map(transformAssistantMessageForSending),
];
// save user's and bot's message
session.messages = session.messages.concat([userMessage, botMessage]);
callbacks.onUpdateMessages(session.messages);
let embeddings: Embedding[] | undefined;
let message;
if (userMessage.urlDetail && !isImageFileType(userMessage.urlDetail.type)) {
// if the user sends document, let the LLM summarize the content of the URL and just use the document's embeddings
message = "Summarize the given context briefly in 200 words or less";
embeddings = userMessage.urlDetail?.embeddings;
sendMessages = [];
} else {
// collect embeddings of all messages
embeddings = session.messages
.flatMap((message: ChatMessage) => message.urlDetail?.embeddings)
.filter((m) => m !== undefined) as Embedding[];
embeddings = embeddings.length > 0 ? embeddings : undefined;
if (
userMessage.urlDetail?.type &&
isImageFileType(userMessage.urlDetail?.type)
) {
message = [
{
type: "text",
text: userMessage.content,
} as MessageContentDetail,
{
type: "image_url",
image_url: {
url: userMessage.urlDetail.url,
},
} as MessageContentDetail,
];
} else {
message = userMessage.content;
}
}
// make request
const controller = new AbortController();
ChatControllerPool.addController(bot.id, controller);
const api = new LLMApi();
await api.chat({
datasource: bot.datasource,
embeddings,
message: message,
chatHistory: sendMessages,
config: modelConfig,
controller,
onUpdate(message) {
if (message) {
botMessage.content = message;
callbacks.onUpdateMessages(session.messages.concat());
}
},
onFinish(memoryMessage?: ResponseMessage) {
botMessage.streaming = false;
if (memoryMessage) {
// all optional memory message returned by the LLM
const newChatMessages = createMessage({ ...memoryMessage });
session.messages = session.messages.concat(newChatMessages);
}
callbacks.onUpdateMessages(session.messages.concat());
ChatControllerPool.remove(bot.id);
},
onError(error) {
const isAborted = error.message.includes("aborted");
botMessage.content +=
"\n\n" +
prettyObject({
error: true,
message: error.message,
});
botMessage.streaming = false;
userMessage.isError = !isAborted;
botMessage.isError = !isAborted;
callbacks.onUpdateMessages(session.messages);
ChatControllerPool.remove(bot.id);
console.error("[Chat] failed ", error);
},
});
}
+24
View File
@@ -872,3 +872,27 @@
color: hsl(var(--primary-foreground));
text-decoration: underline;
}
/* Custom CSS for chat message markdown */
.custom-markdown ul {
list-style-type: disc;
margin-left: 20px;
}
.custom-markdown ol {
list-style-type: decimal;
margin-left: 20px;
}
.custom-markdown li {
margin-bottom: 5px;
}
.custom-markdown ol ol {
list-style: lower-alpha;
}
.custom-markdown ul ul,
.custom-markdown ol ol {
margin-left: 20px;
}
-1
View File
@@ -1 +0,0 @@
export type Updater<T> = (updater: (value: T) => void) => void;
-48
View File
@@ -1,48 +0,0 @@
function getDomContentWidth(dom: HTMLElement) {
const style = window.getComputedStyle(dom);
const paddingWidth =
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const width = dom.clientWidth - paddingWidth;
return width;
}
function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) {
let dom = document.getElementById(id);
if (!dom) {
dom = document.createElement("span");
dom.style.position = "absolute";
dom.style.wordBreak = "break-word";
dom.style.fontSize = "14px";
dom.style.transform = "translateY(-200vh)";
dom.style.pointerEvents = "none";
dom.style.opacity = "0";
dom.id = id;
document.body.appendChild(dom);
init?.(dom);
}
return dom!;
}
export function autoGrowTextArea(dom: HTMLTextAreaElement) {
const measureDom = getOrCreateMeasureDom("__measure");
const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => {
dom.innerText = "TEXT_FOR_MEASURE";
});
const width = getDomContentWidth(dom);
measureDom.style.width = width + "px";
measureDom.innerText = dom.value !== "" ? dom.value : "1";
measureDom.style.fontSize = dom.style.fontSize;
const endWithEmptyLine = dom.value.endsWith("\n");
const height = parseFloat(window.getComputedStyle(measureDom).height);
const singleLineHeight = parseFloat(
window.getComputedStyle(singleLineDom).height,
);
const rows =
Math.round(height / singleLineHeight) + (endWithEmptyLine ? 1 : 0);
return rows;
}
-49
View File
@@ -1,49 +0,0 @@
import Locale from "../locales";
export class FileWrap {
private _file: File;
get file(): File {
return this._file;
}
get name(): string {
return this._file.name;
}
get extension(): string {
return this.name.toLowerCase().split(".").pop() || "";
}
get size(): number {
return this._file.size;
}
readData({ asURL }: { asURL?: boolean } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error(Locale.Upload.ParseDataURLFailed));
}
};
reader.onerror = (error) => {
reject(error);
};
if (asURL) {
reader.readAsDataURL(this.file);
} else {
reader.readAsText(this.file);
}
});
}
constructor(file: File) {
this._file = file;
}
}
-13
View File
@@ -1,13 +0,0 @@
export function prettyObject(msg: any) {
const obj = msg;
if (typeof msg !== "string") {
msg = JSON.stringify(msg, null, " ");
}
if (msg === "{}") {
return obj.toString();
}
if (msg.startsWith("```json")) {
return msg;
}
return ["```json", msg, "```"].join("\n");
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
echo -e "\nAdding sources from create-llama..."
# Remove current create-llama folder
rm -rf app/api/chat/config
# rm -rf app/api/files
rm -rf cl
# Run the node command with specified options
npx -y create-llama@0.1.10 \
--framework nextjs \
--template streaming \
--engine context \
--frontend \
--ui shadcn \
--observability none \
--open-ai-key "Set your OpenAI key here" \
--tools none \
--post-install-action none \
--no-llama-parse \
--example-file \
--vector-db none \
--use-pnpm \
-- cl >/dev/null
# copy routes from create-llama to app
# Note: if changes on these routes are needed, copy them to the project's app folder
# cp -r cl/app/api/files app/api/files
cp -r cl/app/api/chat/config app/api/chat/config
# copy example .env file
cp cl/.env .env.development.local
+20 -5
View File
@@ -4,13 +4,15 @@
"license": "MIT",
"scripts": {
"dev": "next dev",
"build": "next build",
"create-llama": "bash create-llama.sh",
"build": "npm run create-llama && next build",
"start": "next start",
"lint": "next lint",
"format:check": "prettier --check --ignore-path .gitignore app",
"format": "prettier --write --ignore-path .gitignore app",
"prepare": "husky install",
"generate": "node ./scripts/generate.mjs"
"generate:win": "tsx app\\api\\chat\\engine\\generate.ts",
"generate": "tsx app/api/chat/engine/generate.ts"
},
"dependencies": {
"@fortaine/fetch-event-source": "^3.0.6",
@@ -39,7 +41,7 @@
"dotenv": "^16.4.5",
"emoji-picker-react": "^4.9.2",
"encoding": "^0.1.13",
"llamaindex": "^0.2.8",
"llamaindex": "^0.3.16",
"lucide-react": "^0.277.0",
"mermaid": "^10.9.0",
"nanoid": "^5.0.7",
@@ -67,7 +69,17 @@
"unified": "^10.1.2",
"unist-util-remove": "^4.0.0",
"use-debounce": "^9.0.4",
"zustand": "^4.5.2"
"zustand": "^4.5.2",
"ai": "^3.0.21",
"react-syntax-highlighter": "^15.5.0",
"@llamaindex/pdf-viewer": "^1.1.1",
"@radix-ui/react-collapsible": "^1.0.3",
"vaul": "^0.9.1",
"uuid": "^9.0.1",
"@e2b/code-interpreter": "^0.0.5",
"@apidevtools/swagger-parser": "^10.1.0",
"got": "10.7.0",
"ajv": "^8.12.0"
},
"devDependencies": {
"@types/node": "^20.12.7",
@@ -82,6 +94,9 @@
"husky": "^8.0.3",
"lint-staged": "^13.3.0",
"prettier": "^3.2.5",
"typescript": "5.1.6"
"typescript": "5.1.6",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/uuid": "^9.0.8",
"tsx": "^4.7.2"
}
}
+7745 -4681
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

-4
View File
@@ -1,4 +0,0 @@
export const DATASOURCES_DIR = "./datasources";
export const DATASOURCES_CACHE_DIR = "./cache";
export const DATASOURCES_CHUNK_SIZE = 512;
export const DATASOURCES_CHUNK_OVERLAP = 20;
-86
View File
@@ -1,86 +0,0 @@
import {
serviceContextFromDefaults,
storageContextFromDefaults,
SimpleDirectoryReader,
VectorStoreIndex,
} from "llamaindex";
import {
DATASOURCES_CACHE_DIR,
DATASOURCES_DIR,
DATASOURCES_CHUNK_SIZE,
DATASOURCES_CHUNK_OVERLAP,
} from "./constants.mjs";
import { exit } from "process";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
async function getRuntime(func) {
const start = Date.now();
await func();
const end = Date.now();
return end - start;
}
async function generateDatasource(serviceContext, datasource) {
console.log(`Generating storage context for datasource '${datasource}'...`);
// Split documents, create embeddings and store them in the storage context
const ms = await getRuntime(async () => {
const storageContext = await storageContextFromDefaults({
persistDir: `${DATASOURCES_CACHE_DIR}/${datasource}`,
});
const documents = await new SimpleDirectoryReader().loadData({
directoryPath: `${DATASOURCES_DIR}/${datasource}`,
});
await VectorStoreIndex.fromDocuments(documents, {
storageContext,
serviceContext,
});
});
console.log(
`Storage context for datasource '${datasource}' successfully generated in ${
ms / 1000
}s.`,
);
}
async function ensureEnv(fileName) {
try {
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const envFileContent = await fs.promises.readFile(
path.join(__dirname, "..", fileName),
);
const envConfig = dotenv.parse(envFileContent);
if (envConfig && envConfig.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envConfig.OPENAI_API_KEY;
} else {
throw new Error(`OPENAI_API_KEY not found in '${fileName}'`);
}
} catch (e) {
console.log(`Error getting OPENAI_API_KEY from ${fileName}: ${e.message}`);
exit(1);
}
console.log(`Using OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`);
}
const datasource = process.argv[2];
if (!datasource) {
console.log("Error: You must provide a datasource as the parameter.");
console.log("Usage: pnpm run generate <datasource>");
exit(1);
}
(async () => {
// get OPENAI_API_KEY from Next.JS's .env.development.local
await ensureEnv(".env.development.local");
const serviceContext = serviceContextFromDefaults({
chunkSize: DATASOURCES_CHUNK_SIZE,
chunkOverlap: DATASOURCES_CHUNK_OVERLAP,
});
await generateDatasource(serviceContext, datasource);
console.log("Finished generating datasource.");
})();
+1
View File
@@ -6,6 +6,7 @@ module.exports = {
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
'./cl/app/components/ui/chat/**/*.{ts,tsx}'
],
theme: {
container: {
+2 -1
View File
@@ -36,6 +36,7 @@
".next/types/**/*.ts",
],
"exclude": [
"node_modules"
"node_modules",
"cl/**/*"
]
}