mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-07 21:29:13 +00:00
Use new chat streaming API to show Khoj train of thought in Obsidian client
This commit is contained in:
@@ -12,6 +12,25 @@ export interface ChatJsonResult {
|
|||||||
inferredQueries?: string[];
|
inferredQueries?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChunkResult {
|
||||||
|
objects: string[];
|
||||||
|
remainder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageChunk {
|
||||||
|
type: string;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessageState {
|
||||||
|
newResponseTextEl: HTMLElement | null;
|
||||||
|
newResponseEl: HTMLElement | null;
|
||||||
|
loadingEllipsis: HTMLElement | null;
|
||||||
|
references: any;
|
||||||
|
rawResponse: string;
|
||||||
|
rawQuery: string;
|
||||||
|
isVoice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Location {
|
interface Location {
|
||||||
region: string;
|
region: string;
|
||||||
@@ -26,6 +45,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
waitingForLocation: boolean;
|
waitingForLocation: boolean;
|
||||||
location: Location;
|
location: Location;
|
||||||
keyPressTimeout: NodeJS.Timeout | null = null;
|
keyPressTimeout: NodeJS.Timeout | null = null;
|
||||||
|
chatMessageState: ChatMessageState;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
|
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
|
||||||
super(leaf, setting);
|
super(leaf, setting);
|
||||||
@@ -410,7 +430,6 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
|
|
||||||
// Convert the message to html, sanitize the message html and render it to the real DOM
|
// Convert the message to html, sanitize the message html and render it to the real DOM
|
||||||
let chatMessageBodyTextEl = this.contentEl.createDiv();
|
let chatMessageBodyTextEl = this.contentEl.createDiv();
|
||||||
chatMessageBodyTextEl.className = "chat-message-text-response";
|
|
||||||
chatMessageBodyTextEl.innerHTML = this.markdownTextToSanitizedHtml(message, this);
|
chatMessageBodyTextEl.innerHTML = this.markdownTextToSanitizedHtml(message, this);
|
||||||
|
|
||||||
// Add a copy button to each chat message, if it doesn't already exist
|
// Add a copy button to each chat message, if it doesn't already exist
|
||||||
@@ -541,11 +560,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
"data-meta": `🏮 Khoj at ${messageTime}`,
|
"data-meta": `🏮 Khoj at ${messageTime}`,
|
||||||
class: `khoj-chat-message khoj`
|
class: `khoj-chat-message khoj`
|
||||||
},
|
},
|
||||||
}).createDiv({
|
})
|
||||||
attr: {
|
|
||||||
class: `khoj-chat-message-text khoj`
|
|
||||||
},
|
|
||||||
}).createDiv();
|
|
||||||
|
|
||||||
// Scroll to bottom after inserting chat messages
|
// Scroll to bottom after inserting chat messages
|
||||||
this.scrollChatToBottom();
|
this.scrollChatToBottom();
|
||||||
@@ -554,14 +569,14 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) {
|
async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) {
|
||||||
this.result += additionalMessage;
|
this.chatMessageState.rawResponse += additionalMessage;
|
||||||
htmlElement.innerHTML = "";
|
htmlElement.innerHTML = "";
|
||||||
// Sanitize the markdown to render
|
// Sanitize the markdown to render
|
||||||
this.result = DOMPurify.sanitize(this.result);
|
this.chatMessageState.rawResponse = DOMPurify.sanitize(this.chatMessageState.rawResponse);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
htmlElement.innerHTML = this.markdownTextToSanitizedHtml(this.result, this);
|
htmlElement.innerHTML = this.markdownTextToSanitizedHtml(this.chatMessageState.rawResponse, this);
|
||||||
// Render action buttons for the message
|
// Render action buttons for the message
|
||||||
this.renderActionButtons(this.result, htmlElement);
|
this.renderActionButtons(this.chatMessageState.rawResponse, htmlElement);
|
||||||
// Scroll to bottom of modal, till the send message input box
|
// Scroll to bottom of modal, till the send message input box
|
||||||
this.scrollChatToBottom();
|
this.scrollChatToBottom();
|
||||||
}
|
}
|
||||||
@@ -854,35 +869,147 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async readChatStream(response: Response, responseElement: HTMLDivElement, isVoice: boolean = false): Promise<void> {
|
collectJsonsInBufferedMessageChunk(chunk: string): ChunkResult {
|
||||||
|
// Collect list of JSON objects and raw strings in the chunk
|
||||||
|
// Return the list of objects and the remaining raw string
|
||||||
|
let startIndex = chunk.indexOf('{');
|
||||||
|
if (startIndex === -1) return { objects: [chunk], remainder: '' };
|
||||||
|
const objects: string[] = [chunk.slice(0, startIndex)];
|
||||||
|
let openBraces = 0;
|
||||||
|
let currentObject = '';
|
||||||
|
|
||||||
|
for (let i = startIndex; i < chunk.length; i++) {
|
||||||
|
if (chunk[i] === '{') {
|
||||||
|
if (openBraces === 0) startIndex = i;
|
||||||
|
openBraces++;
|
||||||
|
}
|
||||||
|
if (chunk[i] === '}') {
|
||||||
|
openBraces--;
|
||||||
|
if (openBraces === 0) {
|
||||||
|
currentObject = chunk.slice(startIndex, i + 1);
|
||||||
|
objects.push(currentObject);
|
||||||
|
currentObject = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects: objects,
|
||||||
|
remainder: openBraces > 0 ? chunk.slice(startIndex) : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
convertMessageChunkToJson(rawChunk: string): MessageChunk {
|
||||||
|
if (rawChunk?.startsWith("{") && rawChunk?.endsWith("}")) {
|
||||||
|
try {
|
||||||
|
let jsonChunk = JSON.parse(rawChunk);
|
||||||
|
if (!jsonChunk.type)
|
||||||
|
jsonChunk = {type: 'message', data: jsonChunk};
|
||||||
|
return jsonChunk;
|
||||||
|
} catch (e) {
|
||||||
|
return {type: 'message', data: rawChunk};
|
||||||
|
}
|
||||||
|
} else if (rawChunk.length > 0) {
|
||||||
|
return {type: 'message', data: rawChunk};
|
||||||
|
}
|
||||||
|
return {type: '', data: ''};
|
||||||
|
}
|
||||||
|
|
||||||
|
processMessageChunk(rawChunk: string): void {
|
||||||
|
const chunk = this.convertMessageChunkToJson(rawChunk);
|
||||||
|
console.debug("Chunk:", chunk);
|
||||||
|
if (!chunk || !chunk.type) return;
|
||||||
|
if (chunk.type === 'status') {
|
||||||
|
console.log(`status: ${chunk.data}`);
|
||||||
|
const statusMessage = chunk.data;
|
||||||
|
this.handleStreamResponse(this.chatMessageState.newResponseTextEl, statusMessage, this.chatMessageState.loadingEllipsis, false);
|
||||||
|
} else if (chunk.type === 'start_llm_response') {
|
||||||
|
console.log("Started streaming", new Date());
|
||||||
|
} else if (chunk.type === 'end_llm_response') {
|
||||||
|
console.log("Stopped streaming", new Date());
|
||||||
|
|
||||||
|
// Automatically respond with voice if the subscribed user has sent voice message
|
||||||
|
if (this.chatMessageState.isVoice && this.setting.userInfo?.is_active)
|
||||||
|
this.textToSpeech(this.chatMessageState.rawResponse);
|
||||||
|
|
||||||
|
// Append any references after all the data has been streamed
|
||||||
|
this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl);
|
||||||
|
|
||||||
|
const liveQuery = this.chatMessageState.rawQuery;
|
||||||
|
// Reset variables
|
||||||
|
this.chatMessageState = {
|
||||||
|
newResponseTextEl: null,
|
||||||
|
newResponseEl: null,
|
||||||
|
loadingEllipsis: null,
|
||||||
|
references: {},
|
||||||
|
rawResponse: "",
|
||||||
|
rawQuery: liveQuery,
|
||||||
|
isVoice: false,
|
||||||
|
};
|
||||||
|
} else if (chunk.type === "references") {
|
||||||
|
this.chatMessageState.references = {"notes": chunk.data.context, "online": chunk.data.online_results};
|
||||||
|
} else if (chunk.type === 'message') {
|
||||||
|
const chunkData = chunk.data;
|
||||||
|
if (typeof chunkData === 'object' && chunkData !== null) {
|
||||||
|
// If chunkData is already a JSON object
|
||||||
|
this.handleJsonResponse(chunkData);
|
||||||
|
} else if (typeof chunkData === 'string' && chunkData.trim()?.startsWith("{") && chunkData.trim()?.endsWith("}")) {
|
||||||
|
// Try process chunk data as if it is a JSON object
|
||||||
|
try {
|
||||||
|
const jsonData = JSON.parse(chunkData.trim());
|
||||||
|
this.handleJsonResponse(jsonData);
|
||||||
|
} catch (e) {
|
||||||
|
this.chatMessageState.rawResponse += chunkData;
|
||||||
|
this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse, this.chatMessageState.loadingEllipsis);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.chatMessageState.rawResponse += chunkData;
|
||||||
|
this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse, this.chatMessageState.loadingEllipsis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleJsonResponse(jsonData: any): void {
|
||||||
|
if (jsonData.image || jsonData.detail) {
|
||||||
|
this.chatMessageState.rawResponse = this.handleImageResponse(jsonData, this.chatMessageState.rawResponse);
|
||||||
|
} else if (jsonData.response) {
|
||||||
|
this.chatMessageState.rawResponse = jsonData.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chatMessageState.newResponseTextEl) {
|
||||||
|
this.chatMessageState.newResponseTextEl.innerHTML = "";
|
||||||
|
this.chatMessageState.newResponseTextEl.appendChild(this.formatHTMLMessage(this.chatMessageState.rawResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readChatStream(response: Response): Promise<void> {
|
||||||
// Exit if response body is empty
|
// Exit if response body is empty
|
||||||
if (response.body == null) return;
|
if (response.body == null) return;
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let netBracketCount = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
// Automatically respond with voice if the subscribed user has sent voice message
|
this.processMessageChunk(buffer);
|
||||||
if (isVoice && this.setting.userInfo?.is_active) this.textToSpeech(this.result);
|
buffer = '';
|
||||||
// Break if the stream is done
|
// Break if the stream is done
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let responseText = decoder.decode(value);
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
if (responseText.includes("### compiled references:")) {
|
buffer += chunk;
|
||||||
// Render any references used to generate the response
|
|
||||||
const [additionalResponse, rawReference] = responseText.split("### compiled references:", 2);
|
|
||||||
await this.renderIncrementalMessage(responseElement, additionalResponse);
|
|
||||||
|
|
||||||
const rawReferenceAsJson = JSON.parse(rawReference);
|
// Check if the buffer contains (0 or more) complete JSON objects
|
||||||
let references = this.extractReferences(rawReferenceAsJson);
|
netBracketCount += (chunk.match(/{/g) || []).length - (chunk.match(/}/g) || []).length;
|
||||||
responseElement.appendChild(this.createReferenceSection(references));
|
if (netBracketCount === 0) {
|
||||||
} else {
|
let chunks = this.collectJsonsInBufferedMessageChunk(buffer);
|
||||||
// Render incremental chat response
|
chunks.objects.forEach((chunk) => this.processMessageChunk(chunk));
|
||||||
await this.renderIncrementalMessage(responseElement, responseText);
|
buffer = chunks.remainder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,69 +1036,45 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
|
|
||||||
// Get chat response from Khoj backend
|
// Get chat response from Khoj backend
|
||||||
let encodedQuery = encodeURIComponent(query);
|
let encodedQuery = encodeURIComponent(query);
|
||||||
let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&n=${this.setting.resultsCount}&client=obsidian&stream=true®ion=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}`;
|
let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&conversation_id=${conversationId}&n=${this.setting.resultsCount}&stream=true&client=obsidian`;
|
||||||
let responseElement = this.createKhojResponseDiv();
|
if (!!this.location) chatUrl += `®ion=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}`;
|
||||||
|
|
||||||
|
let newResponseEl = this.createKhojResponseDiv();
|
||||||
|
let newResponseTextEl = newResponseEl.createDiv();
|
||||||
|
newResponseTextEl.classList.add("khoj-chat-message-text", "khoj");
|
||||||
|
|
||||||
// Temporary status message to indicate that Khoj is thinking
|
// Temporary status message to indicate that Khoj is thinking
|
||||||
this.result = "";
|
|
||||||
let loadingEllipsis = this.createLoadingEllipse();
|
let loadingEllipsis = this.createLoadingEllipse();
|
||||||
responseElement.appendChild(loadingEllipsis);
|
newResponseTextEl.appendChild(loadingEllipsis);
|
||||||
|
|
||||||
|
// Set chat message state
|
||||||
|
this.chatMessageState = {
|
||||||
|
newResponseEl: newResponseEl,
|
||||||
|
newResponseTextEl: newResponseTextEl,
|
||||||
|
loadingEllipsis: loadingEllipsis,
|
||||||
|
references: {},
|
||||||
|
rawQuery: query,
|
||||||
|
rawResponse: "",
|
||||||
|
isVoice: isVoice,
|
||||||
|
};
|
||||||
|
|
||||||
let response = await fetch(chatUrl, {
|
let response = await fetch(chatUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/plain",
|
||||||
"Authorization": `Bearer ${this.setting.khojApiKey}`,
|
"Authorization": `Bearer ${this.setting.khojApiKey}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (response.body === null) {
|
if (response.body === null) throw new Error("Response body is null");
|
||||||
throw new Error("Response body is null");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear loading status message
|
|
||||||
if (responseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) {
|
|
||||||
responseElement.removeChild(loadingEllipsis);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset collated chat result to empty string
|
|
||||||
this.result = "";
|
|
||||||
responseElement.innerHTML = "";
|
|
||||||
if (response.headers.get("content-type") === "application/json") {
|
|
||||||
let responseText = ""
|
|
||||||
try {
|
|
||||||
const responseAsJson = await response.json() as ChatJsonResult;
|
|
||||||
if (responseAsJson.image) {
|
|
||||||
// If response has image field, response is a generated image.
|
|
||||||
if (responseAsJson.intentType === "text-to-image") {
|
|
||||||
responseText += ``;
|
|
||||||
} else if (responseAsJson.intentType === "text-to-image2") {
|
|
||||||
responseText += ``;
|
|
||||||
} else if (responseAsJson.intentType === "text-to-image-v3") {
|
|
||||||
responseText += ``;
|
|
||||||
}
|
|
||||||
const inferredQuery = responseAsJson.inferredQueries?.[0];
|
|
||||||
if (inferredQuery) {
|
|
||||||
responseText += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
|
|
||||||
}
|
|
||||||
} else if (responseAsJson.detail) {
|
|
||||||
responseText = responseAsJson.detail;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If the chunk is not a JSON object, just display it as is
|
|
||||||
responseText = await response.text();
|
|
||||||
} finally {
|
|
||||||
await this.renderIncrementalMessage(responseElement, responseText);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Stream and render chat response
|
// Stream and render chat response
|
||||||
await this.readChatStream(response, responseElement, isVoice);
|
await this.readChatStream(response);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`Khoj chat response failed with\n${err}`);
|
console.error(`Khoj chat response failed with\n${err}`);
|
||||||
let errorMsg = "Sorry, unable to get response from Khoj backend ❤️🩹. Retry or contact developers for help at <a href=mailto:'team@khoj.dev'>team@khoj.dev</a> or <a href='https://discord.gg/BDgyabRM6e'>on Discord</a>";
|
let errorMsg = "Sorry, unable to get response from Khoj backend ❤️🩹. Retry or contact developers for help at <a href=mailto:'team@khoj.dev'>team@khoj.dev</a> or <a href='https://discord.gg/BDgyabRM6e'>on Discord</a>";
|
||||||
responseElement.innerHTML = errorMsg
|
newResponseTextEl.textContent = errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,7 +1299,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
|
|
||||||
handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace = true) {
|
handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace = true) {
|
||||||
if (!newResponseElement) return;
|
if (!newResponseElement) return;
|
||||||
if (newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) {
|
if (replace && newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) {
|
||||||
newResponseElement.removeChild(loadingEllipsis);
|
newResponseElement.removeChild(loadingEllipsis);
|
||||||
}
|
}
|
||||||
if (replace) {
|
if (replace) {
|
||||||
@@ -1206,20 +1309,6 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
this.scrollChatToBottom();
|
this.scrollChatToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCompiledReferences(rawResponseElement: HTMLElement | null, chunk: string, references: any, rawResponse: string) {
|
|
||||||
if (!rawResponseElement || !chunk) return { rawResponse, references };
|
|
||||||
|
|
||||||
const [additionalResponse, rawReference] = chunk.split("### compiled references:", 2);
|
|
||||||
rawResponse += additionalResponse;
|
|
||||||
rawResponseElement.innerHTML = "";
|
|
||||||
rawResponseElement.appendChild(this.formatHTMLMessage(rawResponse));
|
|
||||||
|
|
||||||
const rawReferenceAsJson = JSON.parse(rawReference);
|
|
||||||
references = this.extractReferences(rawReferenceAsJson);
|
|
||||||
|
|
||||||
return { rawResponse, references };
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageResponse(imageJson: any, rawResponse: string) {
|
handleImageResponse(imageJson: any, rawResponse: string) {
|
||||||
if (imageJson.image) {
|
if (imageJson.image) {
|
||||||
const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image";
|
const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image";
|
||||||
@@ -1236,33 +1325,10 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
|
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let references = {};
|
|
||||||
if (imageJson.context && imageJson.context.length > 0) {
|
|
||||||
references = this.extractReferences(imageJson.context);
|
|
||||||
}
|
|
||||||
if (imageJson.detail) {
|
|
||||||
// If response has detail field, response is an error message.
|
// If response has detail field, response is an error message.
|
||||||
rawResponse += imageJson.detail;
|
if (imageJson.detail) rawResponse += imageJson.detail;
|
||||||
}
|
|
||||||
return { rawResponse, references };
|
|
||||||
}
|
|
||||||
|
|
||||||
extractReferences(rawReferenceAsJson: any): object {
|
return rawResponse;
|
||||||
let references: any = {};
|
|
||||||
if (rawReferenceAsJson instanceof Array) {
|
|
||||||
references["notes"] = rawReferenceAsJson;
|
|
||||||
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
|
|
||||||
references["online"] = rawReferenceAsJson;
|
|
||||||
}
|
|
||||||
return references;
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessageToChatBody(rawResponse: string, newResponseElement: HTMLElement | null, references: any) {
|
|
||||||
if (!newResponseElement) return;
|
|
||||||
newResponseElement.innerHTML = "";
|
|
||||||
newResponseElement.appendChild(this.formatHTMLMessage(rawResponse));
|
|
||||||
|
|
||||||
this.finalizeChatBodyResponse(references, newResponseElement);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) {
|
finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user