Files
khoj/src/interface/web/app/common/chatFunctions.ts
Debanjum 64886cd0dd Fix storing code results on server and rendering them on web app
- 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
2025-06-09 23:46:12 -07:00

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 = `![${file.filename}](data:image/${file.filename.split(".").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 = `![${file.filename}](data:text/plain;base64,${file.b64_data})`;
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);
});
}