mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-02 21:19:12 +00:00
- Fix code context data type for validation on server. This would prevent the chat message from being written to history - Handle null code results on web app
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
import { AttachedFileText } from "../components/chatInputArea/chatInputArea";
|
|
import {
|
|
CodeContext,
|
|
Context,
|
|
OnlineContext,
|
|
StreamMessage,
|
|
} from "../components/chatMessage/chatMessage";
|
|
|
|
export interface RawReferenceData {
|
|
context?: Context[];
|
|
onlineContext?: OnlineContext;
|
|
codeContext?: CodeContext;
|
|
}
|
|
|
|
export interface MessageMetadata {
|
|
conversationId: string;
|
|
turnId: string;
|
|
}
|
|
|
|
export interface GeneratedAssetsData {
|
|
images: string[];
|
|
mermaidjsDiagram: string;
|
|
files: AttachedFileText[];
|
|
}
|
|
|
|
export interface ResponseWithIntent {
|
|
intentType: string;
|
|
response: string;
|
|
inferredQueries?: string[];
|
|
}
|
|
|
|
interface MessageChunk {
|
|
type: string;
|
|
data: string | object;
|
|
}
|
|
|
|
export function convertMessageChunkToJson(chunk: string): MessageChunk {
|
|
if (chunk.startsWith("{") && chunk.endsWith("}")) {
|
|
try {
|
|
const jsonChunk = JSON.parse(chunk);
|
|
if (!jsonChunk.type) {
|
|
return {
|
|
type: "message",
|
|
data: jsonChunk,
|
|
};
|
|
}
|
|
return jsonChunk;
|
|
} catch (error) {
|
|
return {
|
|
type: "message",
|
|
data: chunk,
|
|
};
|
|
}
|
|
} else if (chunk.length > 0) {
|
|
return {
|
|
type: "message",
|
|
data: chunk,
|
|
};
|
|
} else {
|
|
return {
|
|
type: "message",
|
|
data: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
function handleJsonResponse(chunkData: any) {
|
|
const jsonData = chunkData as any;
|
|
if (jsonData.image || jsonData.detail) {
|
|
let responseWithIntent = handleImageResponse(chunkData, true);
|
|
return responseWithIntent;
|
|
} else if (jsonData.response) {
|
|
return {
|
|
response: jsonData.response,
|
|
intentType: "",
|
|
inferredQueries: [],
|
|
};
|
|
} else {
|
|
throw new Error("Invalid JSON response");
|
|
}
|
|
}
|
|
|
|
export function processMessageChunk(
|
|
rawChunk: string,
|
|
currentMessage: StreamMessage,
|
|
context: Context[] = [],
|
|
onlineContext: OnlineContext = {},
|
|
codeContext: CodeContext = {},
|
|
): { context: Context[]; onlineContext: OnlineContext; codeContext: CodeContext } {
|
|
const chunk = convertMessageChunkToJson(rawChunk);
|
|
|
|
if (!currentMessage || !chunk || !chunk.type) return { context, onlineContext, codeContext };
|
|
|
|
console.log(`chunk type: ${chunk.type}`);
|
|
|
|
if (chunk.type === "status") {
|
|
console.log(`status: ${chunk.data}`);
|
|
const statusMessage = chunk.data as string;
|
|
currentMessage.trainOfThought.push(statusMessage);
|
|
} else if (chunk.type === "thought") {
|
|
const thoughtChunk = chunk.data as string;
|
|
const lastThoughtIndex = currentMessage.trainOfThought.length - 1;
|
|
const previousThought =
|
|
lastThoughtIndex >= 0 ? currentMessage.trainOfThought[lastThoughtIndex] : "";
|
|
// If the last train of thought started with "Thinking: " append the new thought chunk to it
|
|
if (previousThought.startsWith("**Thinking:** ")) {
|
|
currentMessage.trainOfThought[lastThoughtIndex] += thoughtChunk;
|
|
} else {
|
|
currentMessage.trainOfThought.push(`**Thinking:** ${thoughtChunk}`);
|
|
}
|
|
} else if (chunk.type === "references") {
|
|
const references = chunk.data as RawReferenceData;
|
|
|
|
if (references.context) context = references.context;
|
|
if (references.onlineContext) onlineContext = references.onlineContext;
|
|
if (references.codeContext) codeContext = references.codeContext;
|
|
return { context, onlineContext, codeContext };
|
|
} else if (chunk.type === "metadata") {
|
|
const messageMetadata = chunk.data as MessageMetadata;
|
|
currentMessage.turnId = messageMetadata.turnId;
|
|
} else if (chunk.type === "generated_assets") {
|
|
const generatedAssets = chunk.data as GeneratedAssetsData;
|
|
|
|
if (generatedAssets.images) {
|
|
currentMessage.generatedImages = generatedAssets.images;
|
|
}
|
|
|
|
if (generatedAssets.mermaidjsDiagram) {
|
|
currentMessage.generatedMermaidjsDiagram = generatedAssets.mermaidjsDiagram;
|
|
}
|
|
|
|
if (generatedAssets.files) {
|
|
currentMessage.generatedFiles = generatedAssets.files;
|
|
}
|
|
} else if (chunk.type === "message") {
|
|
const chunkData = chunk.data;
|
|
// Here, handle if the response is a JSON response with an image, but the intentType is excalidraw
|
|
if (chunkData !== null && typeof chunkData === "object") {
|
|
let responseWithIntent = handleJsonResponse(chunkData);
|
|
|
|
if (responseWithIntent.intentType && responseWithIntent.intentType === "excalidraw") {
|
|
currentMessage.rawResponse = responseWithIntent.response;
|
|
} else {
|
|
currentMessage.rawResponse += responseWithIntent.response;
|
|
}
|
|
|
|
currentMessage.intentType = responseWithIntent.intentType;
|
|
currentMessage.inferredQueries = responseWithIntent.inferredQueries;
|
|
} else if (
|
|
typeof chunkData === "string" &&
|
|
chunkData.trim()?.startsWith("{") &&
|
|
chunkData.trim()?.endsWith("}")
|
|
) {
|
|
try {
|
|
const jsonData = JSON.parse(chunkData.trim());
|
|
let responseWithIntent = handleJsonResponse(jsonData);
|
|
currentMessage.rawResponse += responseWithIntent.response;
|
|
currentMessage.intentType = responseWithIntent.intentType;
|
|
currentMessage.inferredQueries = responseWithIntent.inferredQueries;
|
|
} catch (e) {
|
|
currentMessage.rawResponse += JSON.stringify(chunkData);
|
|
}
|
|
} else {
|
|
currentMessage.rawResponse += chunkData;
|
|
}
|
|
} else if (chunk.type === "start_llm_response") {
|
|
console.log(`Started streaming: ${new Date()}`);
|
|
} else if (chunk.type === "end_llm_response") {
|
|
console.log(`Completed streaming: ${new Date()}`);
|
|
} else if (chunk.type === "end_response") {
|
|
// Append any references after all the data has been streamed
|
|
if (codeContext) currentMessage.codeContext = codeContext;
|
|
if (onlineContext) currentMessage.onlineContext = onlineContext;
|
|
if (context) currentMessage.context = context;
|
|
|
|
// Mark current message streaming as completed
|
|
currentMessage.completed = true;
|
|
}
|
|
return { context, onlineContext, codeContext };
|
|
}
|
|
|
|
export function handleImageResponse(imageJson: any, liveStream: boolean): ResponseWithIntent {
|
|
let rawResponse = "";
|
|
|
|
if (imageJson.image) {
|
|
// If response has image field, response may be a generated image
|
|
rawResponse = imageJson.image;
|
|
}
|
|
|
|
let responseWithIntent: ResponseWithIntent = {
|
|
intentType: imageJson.intentType,
|
|
response: rawResponse,
|
|
inferredQueries: imageJson.inferredQueries,
|
|
};
|
|
|
|
if (imageJson.detail) {
|
|
// The detail field contains the improved image prompt
|
|
rawResponse += imageJson.detail;
|
|
}
|
|
|
|
return responseWithIntent;
|
|
}
|
|
|
|
export function renderCodeGenImageInline(message: string, codeContext: CodeContext) {
|
|
if (!codeContext) return message;
|
|
|
|
Object.values(codeContext).forEach((contextData) => {
|
|
contextData.results?.output_files?.forEach((file) => {
|
|
const regex = new RegExp(`!?\\[.*?\\]\\(.*${file.filename}\\)`, "g");
|
|
if (file.filename.match(/\.(png|jpg|jpeg)$/i)) {
|
|
const replacement = `.pop()};base64,${file.b64_data})`;
|
|
message = message.replace(regex, replacement);
|
|
} else if (file.filename.match(/\.(txt|org|md|csv|json)$/i)) {
|
|
// render output files generated by codegen as downloadable links
|
|
const replacement = ``;
|
|
message = message.replace(regex, replacement);
|
|
}
|
|
});
|
|
});
|
|
|
|
return message;
|
|
}
|
|
|
|
export function modifyFileFilterForConversation(
|
|
conversationId: string | null,
|
|
filenames: string[],
|
|
setAddedFiles: (files: string[]) => void,
|
|
mode: "add" | "remove",
|
|
) {
|
|
if (!conversationId) {
|
|
console.error("No conversation ID provided");
|
|
return;
|
|
}
|
|
|
|
const method = mode === "add" ? "POST" : "DELETE";
|
|
|
|
const body = {
|
|
conversation_id: conversationId,
|
|
filenames: filenames,
|
|
};
|
|
const addUrl = `/api/chat/conversation/file-filters/bulk`;
|
|
|
|
fetch(addUrl, {
|
|
method: method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok)
|
|
throw new Error(`Failed to call API at ${addUrl} with error ${res.statusText}`);
|
|
return res.json();
|
|
})
|
|
.then((data) => {
|
|
setAddedFiles(data);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
return;
|
|
});
|
|
}
|
|
|
|
export async function createNewConversation(slug: string) {
|
|
try {
|
|
const response = await fetch(`/api/chat/sessions?client=web&agent_slug=${slug}`, {
|
|
method: "POST",
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`Failed to fetch chat sessions with status: ${response.status}`);
|
|
const data = await response.json();
|
|
const conversationID = data.conversation_id;
|
|
if (!conversationID) throw new Error("Conversation ID not found in response");
|
|
return conversationID;
|
|
} catch (error) {
|
|
console.error("Error creating new conversation:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function packageFilesForUpload(files: FileList): Promise<FormData> {
|
|
const formData = new FormData();
|
|
|
|
const fileReadPromises = Array.from(files).map((file) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
let reader = new FileReader();
|
|
reader.onload = function (event) {
|
|
if (event.target === null) {
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
let fileContents = event.target.result;
|
|
let fileType = file.type;
|
|
let fileName = file.name;
|
|
if (fileType === "") {
|
|
let fileExtension = fileName.split(".").pop();
|
|
if (fileExtension === "org") {
|
|
fileType = "text/org";
|
|
} else if (fileExtension === "md") {
|
|
fileType = "text/markdown";
|
|
} else if (
|
|
fileExtension === "txt" ||
|
|
fileExtension === "tsx" ||
|
|
fileExtension === "ipynb"
|
|
) {
|
|
fileType = "text/plain";
|
|
} else if (fileExtension === "html") {
|
|
fileType = "text/html";
|
|
} else if (fileExtension === "pdf") {
|
|
fileType = "application/pdf";
|
|
} else if (fileExtension === "docx") {
|
|
fileType =
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
} else {
|
|
// Skip this file if its type is not supported
|
|
console.warn(
|
|
`File type ${fileType} not supported. Skipping file: ${fileName}`,
|
|
);
|
|
resolve();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (fileContents === null) {
|
|
console.warn(`Could not read file content. Skipping file: ${fileName}`);
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
let fileObj = new Blob([fileContents], { type: fileType });
|
|
formData.append("files", fileObj, file.name);
|
|
resolve();
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
});
|
|
|
|
await Promise.all(fileReadPromises);
|
|
return formData;
|
|
}
|
|
|
|
export function generateNewTitle(conversationId: string, setTitle: (title: string) => void) {
|
|
fetch(`/api/chat/title?conversation_id=${conversationId}`, {
|
|
method: "POST",
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`Failed to call API with error ${res.statusText}`);
|
|
return res.json();
|
|
})
|
|
.then((data) => {
|
|
setTitle(data.title);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
return;
|
|
});
|
|
}
|
|
|
|
export function uploadDataForIndexing(
|
|
files: FileList,
|
|
setWarning: (warning: string) => void,
|
|
setUploading: (uploading: boolean) => void,
|
|
setError: (error: string) => void,
|
|
setUploadedFiles?: (files: string[]) => void,
|
|
conversationId?: string | null,
|
|
) {
|
|
const allowedExtensions = [
|
|
"text/org",
|
|
"text/markdown",
|
|
"text/plain",
|
|
"text/html",
|
|
"application/pdf",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
];
|
|
const allowedFileEndings = ["org", "md", "txt", "html", "pdf", "docx"];
|
|
const badFiles: string[] = [];
|
|
const goodFiles: File[] = [];
|
|
|
|
const uploadedFiles: string[] = [];
|
|
|
|
for (let file of files) {
|
|
const fileEnding = file.name.split(".").pop();
|
|
if (!file || !file.name || !fileEnding) {
|
|
if (file) {
|
|
badFiles.push(file.name);
|
|
}
|
|
} else if (
|
|
!allowedExtensions.includes(file.type) &&
|
|
!allowedFileEndings.includes(fileEnding.toLowerCase())
|
|
) {
|
|
badFiles.push(file.name);
|
|
} else {
|
|
goodFiles.push(file);
|
|
}
|
|
}
|
|
|
|
if (goodFiles.length === 0) {
|
|
setWarning("No supported files found");
|
|
return;
|
|
}
|
|
|
|
if (badFiles.length > 0) {
|
|
setWarning("The following files are not supported yet:\n" + badFiles.join("\n"));
|
|
}
|
|
|
|
const formData = new FormData();
|
|
|
|
// Create an array of Promises for file reading
|
|
const fileReadPromises = Array.from(goodFiles).map((file) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
let reader = new FileReader();
|
|
reader.onload = function (event) {
|
|
if (event.target === null) {
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
let fileContents = event.target.result;
|
|
let fileType = file.type;
|
|
let fileName = file.name;
|
|
if (fileType === "") {
|
|
let fileExtension = fileName.split(".").pop();
|
|
if (fileExtension === "org") {
|
|
fileType = "text/org";
|
|
} else if (fileExtension === "md") {
|
|
fileType = "text/markdown";
|
|
} else if (fileExtension === "txt") {
|
|
fileType = "text/plain";
|
|
} else if (fileExtension === "html") {
|
|
fileType = "text/html";
|
|
} else if (fileExtension === "pdf") {
|
|
fileType = "application/pdf";
|
|
} else {
|
|
// Skip this file if its type is not supported
|
|
resolve();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (fileContents === null) {
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
let fileObj = new Blob([fileContents], { type: fileType });
|
|
formData.append("files", fileObj, file.name);
|
|
resolve();
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
});
|
|
|
|
setUploading(true);
|
|
|
|
// Wait for all files to be read before making the fetch request
|
|
Promise.all(fileReadPromises)
|
|
.then(() => {
|
|
return fetch("/api/content?client=web", {
|
|
method: "PATCH",
|
|
body: formData,
|
|
});
|
|
})
|
|
.then((data) => {
|
|
for (let file of goodFiles) {
|
|
uploadedFiles.push(file.name);
|
|
if (conversationId && setUploadedFiles) {
|
|
modifyFileFilterForConversation(
|
|
conversationId,
|
|
[file.name],
|
|
setUploadedFiles,
|
|
"add",
|
|
);
|
|
}
|
|
}
|
|
if (setUploadedFiles) setUploadedFiles(uploadedFiles);
|
|
})
|
|
.catch((error) => {
|
|
console.log(error);
|
|
setError(`Error uploading file: ${error}`);
|
|
})
|
|
.finally(() => {
|
|
setUploading(false);
|
|
});
|
|
}
|