1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-10-22 07:52:19 +03:00

Vectors: Finished core fetch & display functionality

This commit is contained in:
Dan Brown
2025-08-22 12:59:32 +01:00
parent 8eef5a1ee7
commit bb08f62327
4 changed files with 77 additions and 51 deletions

View File

@@ -3,7 +3,6 @@
namespace BookStack\Search\Queries; namespace BookStack\Search\Queries;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Search\SearchOptions;
use BookStack\Search\SearchRunner; use BookStack\Search\SearchRunner;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -12,6 +11,13 @@ class QueryController extends Controller
public function __construct( public function __construct(
protected SearchRunner $searchRunner, protected SearchRunner $searchRunner,
) { ) {
// TODO - Check via testing
$this->middleware(function ($request, $next) {
if (!VectorQueryServiceProvider::isEnabled()) {
$this->showPermissionError('/');
}
return $next($request);
});
} }
/** /**
@@ -19,17 +25,12 @@ class QueryController extends Controller
*/ */
public function show(Request $request) public function show(Request $request)
{ {
// TODO - Validate if query system is active
$query = $request->get('ask', ''); $query = $request->get('ask', '');
// TODO - Placeholder
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString("cat"), 'all', 1, 20)['results'];
// TODO - Set page title // TODO - Set page title
return view('search.query', [ return view('search.query', [
'query' => $query, 'query' => $query,
'entities' => $entities,
]); ]);
} }
@@ -38,16 +39,23 @@ class QueryController extends Controller
*/ */
public function run(Request $request, VectorSearchRunner $searchRunner, LlmQueryRunner $llmRunner) public function run(Request $request, VectorSearchRunner $searchRunner, LlmQueryRunner $llmRunner)
{ {
// TODO - Validate if query system is active // TODO - Rate limiting
$query = $request->get('query', ''); $query = $request->get('query', '');
return response()->eventStream(function () use ($query, $searchRunner, $llmRunner) { return response()->eventStream(function () use ($query, $searchRunner, $llmRunner) {
$results = $query ? $searchRunner->run($query) : []; $results = $query ? $searchRunner->run($query) : [];
$count = count($results); $entities = [];
yield "Found {$count} results for query: {$query}!"; foreach ($results as $result) {
$llmResult = $llmRunner->run($query, $results); $entityKey = $result->entity->getMorphClass() . ':' . $result->entity->id;
yield "LLM result: {$llmResult}"; if (!isset($entities[$entityKey])) {
$entities[$entityKey] = $result->entity;
}
}
yield ['view' => view('entities.list', ['entities' => $entities])->render()];
yield ['result' => $llmRunner->run($query, $results)];
}); });
} }
} }

View File

@@ -1,5 +1,4 @@
import {Component} from "./component"; import {Component} from "./component";
import {createEventSource} from "eventsource-client";
export class QueryManager extends Component { export class QueryManager extends Component {
protected input!: HTMLTextAreaElement; protected input!: HTMLTextAreaElement;
@@ -8,33 +7,52 @@ export class QueryManager extends Component {
protected contentLoading!: HTMLElement; protected contentLoading!: HTMLElement;
protected contentDisplay!: HTMLElement; protected contentDisplay!: HTMLElement;
protected form!: HTMLFormElement; protected form!: HTMLFormElement;
protected fieldset!: HTMLFieldSetElement;
setup() { setup() {
this.input = this.$refs.input as HTMLTextAreaElement; this.input = this.$refs.input as HTMLTextAreaElement;
this.form = this.$refs.form as HTMLFormElement; this.form = this.$refs.form as HTMLFormElement;
this.fieldset = this.$refs.fieldset as HTMLFieldSetElement;
this.generatedLoading = this.$refs.generatedLoading; this.generatedLoading = this.$refs.generatedLoading;
this.generatedDisplay = this.$refs.generatedDisplay; this.generatedDisplay = this.$refs.generatedDisplay;
this.contentLoading = this.$refs.contentLoading; this.contentLoading = this.$refs.contentLoading;
this.contentDisplay = this.$refs.contentDisplay; this.contentDisplay = this.$refs.contentDisplay;
// TODO - Start lookup if query set this.setupListeners();
// TODO - Update URL on query change // Start lookup if a query is set
if (this.input.value.trim() !== '') {
this.runQuery();
}
}
// TODO - Handle query form submission protected setupListeners(): void {
// Handle form submission
this.form.addEventListener('submit', event => { this.form.addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
this.runQuery(); this.runQuery();
}); });
// Allow Ctrl+Enter to run a query
this.input.addEventListener('keydown', event => {
if (event.key === 'Enter' && event.ctrlKey && this.input.value.trim() !== '') {
this.runQuery();
}
});
} }
async runQuery() { protected async runQuery(): Promise<void> {
this.contentLoading.hidden = false; this.contentLoading.hidden = false;
this.generatedLoading.hidden = false; this.generatedLoading.hidden = false;
this.contentDisplay.innerHTML = ''; this.contentDisplay.innerHTML = '';
this.generatedDisplay.innerHTML = ''; this.generatedDisplay.innerHTML = '';
this.fieldset.disabled = true;
const query = this.input.value.trim();
const url = new URL(window.location.href);
url.searchParams.set('ask', query);
window.history.pushState({}, '', url.toString());
const query = this.input.value;
const es = window.$http.eventSource('/query', 'POST', {query}); const es = window.$http.eventSource('/query', 'POST', {query});
let messageCount = 0; let messageCount = 0;
@@ -42,16 +60,18 @@ export class QueryManager extends Component {
messageCount++; messageCount++;
if (messageCount === 1) { if (messageCount === 1) {
// Entity results // Entity results
this.contentDisplay.innerText = data; // TODO - Update to HTML this.contentDisplay.innerHTML = JSON.parse(data).view;
this.contentLoading.hidden = true; this.contentLoading.hidden = true;
} else if (messageCount === 2) { } else if (messageCount === 2) {
// LLM Output // LLM Output
this.generatedDisplay.innerText = data; // TODO - Update to HTML this.generatedDisplay.innerText = JSON.parse(data).result;
this.generatedLoading.hidden = true; this.generatedLoading.hidden = true;
} else { } else {
es.close() es.close();
break; break;
} }
} }
this.fieldset.disabled = false;
} }
} }

View File

@@ -614,4 +614,12 @@ input.shortcut-input {
margin: 0; margin: 0;
font-size: 1.6rem; font-size: 1.6rem;
} }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
} }

View File

@@ -8,55 +8,45 @@
<form action="{{ url('/query') }}" <form action="{{ url('/query') }}"
refs="query-manager@form" refs="query-manager@form"
title="Run Query" title="Run Query"
method="post" method="post">
class="query-form"> <fieldset class="query-form" refs="query-manager@fieldset">
<textarea name="query" <textarea name="query"
refs="query-manager@input" refs="query-manager@input"
class="input-fill-width" class="input-fill-width"
rows="5" rows="5"
placeholder="Enter a query" placeholder="Enter a query"
autocomplete="off">{{ $query }}</textarea> autocomplete="off">{{ $query }}</textarea>
<button class="button icon">@icon('search')</button> <button class="button icon">@icon('search')</button>
</fieldset>
</form> </form>
</div> </div>
<div class="card content-wrap auto-height pb-xl"> <div class="card content-wrap auto-height pb-xl">
<h2 class="list-heading">Generated Response</h2> <h2 class="list-heading">Generated Response</h2>
<div refs="query-manager@generated-loading"> <div refs="query-manager@generated-loading" hidden>
@include('common.loading-icon') @include('common.loading-icon')
</div> </div>
<p refs="query-manager@generated-display">Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad adipisci aliquid architecto cupiditate dolor doloribus eligendi et expedita facilis fugiat fugit illo, ipsa laboriosam maiores, molestias mollitia non obcaecati porro quasi quis quos reprehenderit rerum sunt tenetur ullam unde voluptate voluptates! Distinctio et eum id molestiae nisi quisquam sed ut.</p> <p refs="query-manager@generated-display">
<span class="text-muted italic">
When you run a query, the relevant content found & shown below will be used to help generate a smart machine generated response.
</span>
</p>
</div> </div>
<div class="card content-wrap auto-height pb-xl"> <div class="card content-wrap auto-height pb-xl">
<h2 class="list-heading">Relevant Content</h2> <h2 class="list-heading">Relevant Content</h2>
<div refs="query-manager@content-loading"> <div refs="query-manager@content-loading" hidden>
@include('common.loading-icon') @include('common.loading-icon')
</div> </div>
<div class="book-contents"> <div class="book-contents">
<div refs="query-manager@content-display" class="entity-list"> <div refs="query-manager@content-display" class="entity-list">
@include('entities.list', ['entities' => $entities, 'showPath' => true, 'showTags' => true]) <p class="text-muted italic mx-m">
Start a query to find relevant matching content.
The items shown here reflect those used to help provide the above response.
</p>
</div> </div>
</div> </div>
</div> </div>
{{-- @if($results)--}}
{{-- <h2>Results</h2>--}}
{{-- <h3>LLM Output</h3>--}}
{{-- <p>{{ $results['llm_result'] }}</p>--}}
{{-- <h3>Entity Matches</h3>--}}
{{-- @foreach($results['entity_matches'] as $match)--}}
{{-- <div>--}}
{{-- <div><strong>{{ $match['entity_type'] }}:{{ $match['entity_id'] }}; Distance: {{ $match['distance'] }}</strong></div>--}}
{{-- <details>--}}
{{-- <summary>match text</summary>--}}
{{-- <div>{{ $match['text'] }}</div>--}}
{{-- </details>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- @endif--}}
</div> </div>
@stop @stop