1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-10-20 20:12:39 +03:00

Vectors: Updated query response to use server-side-events

Allowing the vector query results and the LLM response to each come back
over the same HTTP request at two different times via a somewhat
standard.

Uses a package for JS SSE client, since native browser client does not
support over POST, which is probably important for this endpoint as we
don't want crawlers or other bots abusing this via accidentally.
This commit is contained in:
Dan Brown
2025-08-21 16:03:55 +01:00
parent 88ccd9e5b9
commit 8eef5a1ee7
5 changed files with 88 additions and 7 deletions

View File

@@ -41,8 +41,13 @@ class QueryController extends Controller
// TODO - Validate if query system is active
$query = $request->get('query', '');
$results = $query ? $searchRunner->run($query) : [];
$llmResult = $llmRunner->run($query, $results);
dd($results, $llmResult);
return response()->eventStream(function () use ($query, $searchRunner, $llmRunner) {
$results = $query ? $searchRunner->run($query) : [];
$count = count($results);
yield "Found {$count} results for query: {$query}!";
$llmResult = $llmRunner->run($query, $results);
yield "LLM result: {$llmResult}";
});
}
}

22
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@types/jest": "^29.5.14",
"codemirror": "^6.0.1",
"eventsource-client": "^1.1.4",
"idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
@@ -4336,6 +4337,27 @@
"node": ">=0.10.0"
}
},
"node_modules/eventsource-client": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/eventsource-client/-/eventsource-client-1.1.4.tgz",
"integrity": "sha512-CKnqZTwXCnHN2EqrEB9eLSjMMRqHum09VOsikkgSPoa2Jr2XgQnX7P1Fxhnnj/UHxi3GQ2xVsXDKIktEes07bg==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz",
"integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",

View File

@@ -55,6 +55,7 @@
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@types/jest": "^29.5.14",
"codemirror": "^6.0.1",
"eventsource-client": "^1.1.4",
"idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",

View File

@@ -1,4 +1,5 @@
import {Component} from "./component";
import {createEventSource} from "eventsource-client";
export class QueryManager extends Component {
protected input!: HTMLTextAreaElement;
@@ -21,5 +22,36 @@ export class QueryManager extends Component {
// TODO - Update URL on query change
// TODO - Handle query form submission
this.form.addEventListener('submit', event => {
event.preventDefault();
this.runQuery();
});
}
async runQuery() {
this.contentLoading.hidden = false;
this.generatedLoading.hidden = false;
this.contentDisplay.innerHTML = '';
this.generatedDisplay.innerHTML = '';
const query = this.input.value;
const es = window.$http.eventSource('/query', 'POST', {query});
let messageCount = 0;
for await (const {data, event, id} of es) {
messageCount++;
if (messageCount === 1) {
// Entity results
this.contentDisplay.innerText = data; // TODO - Update to HTML
this.contentLoading.hidden = true;
} else if (messageCount === 2) {
// LLM Output
this.generatedDisplay.innerText = data; // TODO - Update to HTML
this.generatedLoading.hidden = true;
} else {
es.close()
break;
}
}
}
}

View File

@@ -1,3 +1,5 @@
import {createEventSource, EventSourceClient} from "eventsource-client";
type ResponseData = Record<any, any>|string;
type RequestOptions = {
@@ -59,7 +61,6 @@ export class HttpManager {
}
createXMLHttpRequest(method: string, url: string, events: Record<string, (e: Event) => void> = {}): XMLHttpRequest {
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
@@ -68,7 +69,7 @@ export class HttpManager {
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken || '');
req.setRequestHeader('X-CSRF-TOKEN', this.getCSRFToken());
return req;
}
@@ -95,12 +96,11 @@ export class HttpManager {
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || '';
const requestOptions: RequestInit = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
'X-CSRF-TOKEN': this.getCSRFToken(),
};
const response = await fetch(requestUrl, requestOptions);
@@ -191,6 +191,27 @@ export class HttpManager {
return this.dataRequest('DELETE', url, data);
}
eventSource(url: string, method: string = 'GET', body: object = {}): EventSourceClient {
if (!url.startsWith('http')) {
url = window.baseUrl(url);
}
return createEventSource({
url,
method,
body: JSON.stringify(body),
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.getCSRFToken(),
}
});
}
protected getCSRFToken(): string {
return document.querySelector('meta[name=token]')?.getAttribute('content') || '';
}
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including