mirror of
https://github.com/run-llama/chat-llamaindex.git
synced 2026-06-30 21:08:02 -04:00
feat: use create-llama chat session (#94)
--------- Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
@@ -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
|
||||
@@ -46,3 +46,8 @@ dev
|
||||
*.key.pub
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
# create-llama copies
|
||||
app/api/chat/config/
|
||||
app/api/files/
|
||||
cl/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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.");
|
||||
})();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const STORAGE_CACHE_DIR = "./cache";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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],
|
||||
}));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 />,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
export * from "./session";
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export type Updater<T> = (updater: (value: T) => void) => void;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+7745
-4681
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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;
|
||||
@@ -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.");
|
||||
})();
|
||||
@@ -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
@@ -36,6 +36,7 @@
|
||||
".next/types/**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"cl/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user