From: Dan Brown Date: Thu, 21 Aug 2025 15:03:55 +0000 (+0100) Subject: Vectors: Updated query response to use server-side-events X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/refs/heads/vectors 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. --- diff --git a/app/Search/Queries/QueryController.php b/app/Search/Queries/QueryController.php index 95888a88f..cfaf2e920 100644 --- a/app/Search/Queries/QueryController.php +++ b/app/Search/Queries/QueryController.php @@ -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}"; + }); } } diff --git a/package-lock.json b/package-lock.json index 079e39770..86bdc05e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 151338d8c..637457a93 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/components/query-manager.ts b/resources/js/components/query-manager.ts index 9252c543d..40a71489b 100644 --- a/resources/js/components/query-manager.ts +++ b/resources/js/components/query-manager.ts @@ -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; + } + } } } \ No newline at end of file diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts index f9eaafc39..07f150220 100644 --- a/resources/js/services/http.ts +++ b/resources/js/services/http.ts @@ -1,3 +1,5 @@ +import {createEventSource, EventSourceClient} from "eventsource-client"; + type ResponseData = Record|string; type RequestOptions = { @@ -59,7 +61,6 @@ export class HttpManager { } createXMLHttpRequest(method: string, url: string, events: Record 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