diff --git a/app/Config/services.php b/app/Config/services.php index d7345823150..aafe0bacc99 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -22,6 +22,18 @@ // Callback URL for social authentication methods 'callback_url' => env('APP_URL', false), + // LLM Service + // Options: openai + 'llm' => env('LLM_SERVICE', ''), + + // OpenAI API-compatible service details + 'openai' => [ + 'endpoint' => env('OPENAI_ENDPOINT', 'https://api.openai.com'), + 'key' => env('OPENAI_KEY', ''), + 'embedding_model' => env('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'), + 'query_model' => env('OPENAI_QUERY_MODEL', 'gpt-4o'), + ], + 'github' => [ 'client_id' => env('GITHUB_APP_ID', false), 'client_secret' => env('GITHUB_APP_SECRET', false), diff --git a/app/Console/Commands/RegenerateVectorsCommand.php b/app/Console/Commands/RegenerateVectorsCommand.php new file mode 100644 index 00000000000..26259e94345 --- /dev/null +++ b/app/Console/Commands/RegenerateVectorsCommand.php @@ -0,0 +1,46 @@ +delete(); + + $types = $entityProvider->all(); + foreach ($types as $type => $typeInstance) { + $this->info("Creating jobs to store vectors for {$type} data..."); + /** @var Entity[] $entities */ + $typeInstance->newQuery()->chunkById(100, function ($entities) { + foreach ($entities as $entity) { + dispatch(new StoreEntityVectorsJob($entity)); + } + }); + } + } +} diff --git a/app/Search/Queries/EntityVectorGenerator.php b/app/Search/Queries/EntityVectorGenerator.php new file mode 100644 index 00000000000..34e37eb0343 --- /dev/null +++ b/app/Search/Queries/EntityVectorGenerator.php @@ -0,0 +1,89 @@ +vectorQueryServiceProvider->get(); + + $text = $this->entityToPlainText($entity); + $chunks = $this->chunkText($text); + $embeddings = $this->chunksToEmbeddings($chunks, $vectorService); + + $this->deleteExistingEmbeddingsForEntity($entity); + $this->storeEmbeddings($embeddings, $chunks, $entity); + } + + protected function deleteExistingEmbeddingsForEntity(Entity $entity): void + { + SearchVector::query() + ->where('entity_type', '=', $entity->getMorphClass()) + ->where('entity_id', '=', $entity->id) + ->delete(); + } + + protected function storeEmbeddings(array $embeddings, array $textChunks, Entity $entity): void + { + $toInsert = []; + + foreach ($embeddings as $index => $embedding) { + $text = $textChunks[$index]; + $toInsert[] = [ + 'entity_id' => $entity->id, + 'entity_type' => $entity->getMorphClass(), + 'embedding' => DB::raw('VEC_FROMTEXT("[' . implode(',', $embedding) . ']")'), + 'text' => $text, + ]; + } + + $chunks = array_chunk($toInsert, 500); + foreach ($chunks as $chunk) { + SearchVector::query()->insert($chunk); + } + } + + /** + * @param string[] $chunks + * @return float[] array + */ + protected function chunksToEmbeddings(array $chunks, VectorQueryService $vectorQueryService): array + { + $embeddings = []; + foreach ($chunks as $index => $chunk) { + $embeddings[$index] = $vectorQueryService->generateEmbeddings($chunk); + } + return $embeddings; + } + + /** + * @return string[] + */ + protected function chunkText(string $text): array + { + return (new TextChunker(500, ["\n", '.', ' ', '']))->chunk($text); + } + + protected function entityToPlainText(Entity $entity): string + { + $tags = $entity->tags()->get(); + $tagText = $tags->map(function (Tag $tag) { + return $tag->name . ': ' . $tag->value; + })->join('\n'); + + return $entity->name . "\n{$tagText}\n" . $entity->{$entity->textField}; + } +} diff --git a/app/Search/Queries/LlmQueryRunner.php b/app/Search/Queries/LlmQueryRunner.php new file mode 100644 index 00000000000..cbd1b617c14 --- /dev/null +++ b/app/Search/Queries/LlmQueryRunner.php @@ -0,0 +1,26 @@ +vectorQueryServiceProvider->get(); + + $matchesText = array_values(array_map(fn (VectorSearchResult $result) => $result->matchText, $vectorResults)); + return $queryService->query($query, $matchesText); + } +} diff --git a/app/Search/Queries/QueryController.php b/app/Search/Queries/QueryController.php new file mode 100644 index 00000000000..4d8c71b6184 --- /dev/null +++ b/app/Search/Queries/QueryController.php @@ -0,0 +1,61 @@ +middleware(function ($request, $next) { + if (!VectorQueryServiceProvider::isEnabled()) { + $this->showPermissionError('/'); + } + return $next($request); + }); + } + + /** + * Show the view to start a vector/LLM-based query search. + */ + public function show(Request $request) + { + $query = $request->get('ask', ''); + + // TODO - Set page title + + return view('search.query', [ + 'query' => $query, + ]); + } + + /** + * Perform a vector/LLM-based query search. + */ + public function run(Request $request, VectorSearchRunner $searchRunner, LlmQueryRunner $llmRunner) + { + // TODO - Rate limiting + $query = $request->get('query', ''); + + return response()->eventStream(function () use ($query, $searchRunner, $llmRunner) { + $results = $query ? $searchRunner->run($query) : []; + + $entities = []; + foreach ($results as $result) { + $entityKey = $result->entity->getMorphClass() . ':' . $result->entity->id; + if (!isset($entities[$entityKey])) { + $entities[$entityKey] = $result->entity; + } + } + + yield ['view' => view('entities.list', ['entities' => $entities])->render()]; + + yield ['result' => $llmRunner->run($query, $results)]; + }); + } +} diff --git a/app/Search/Queries/SearchVector.php b/app/Search/Queries/SearchVector.php new file mode 100644 index 00000000000..fcad45da608 --- /dev/null +++ b/app/Search/Queries/SearchVector.php @@ -0,0 +1,26 @@ +hasMany(JointPermission::class, 'entity_id', 'entity_id') + ->whereColumn('search_vectors.entity_type', '=', 'joint_permissions.entity_type'); + } +} diff --git a/app/Search/Queries/Services/OpenAiVectorQueryService.php b/app/Search/Queries/Services/OpenAiVectorQueryService.php new file mode 100644 index 00000000000..9bd9080ba11 --- /dev/null +++ b/app/Search/Queries/Services/OpenAiVectorQueryService.php @@ -0,0 +1,66 @@ +key = $this->options['key'] ?? ''; + $this->endpoint = $this->options['endpoint'] ?? ''; + $this->embeddingModel = $this->options['embedding_model'] ?? ''; + $this->queryModel = $this->options['query_model'] ?? ''; + } + + protected function jsonRequest(string $method, string $uri, array $data): array + { + $fullUrl = rtrim($this->endpoint, '/') . '/' . ltrim($uri, '/'); + $client = $this->http->buildClient(30); + $request = $this->http->jsonRequest($method, $fullUrl, $data) + ->withHeader('Authorization', 'Bearer ' . $this->key); + + $response = $client->sendRequest($request); + return json_decode($response->getBody()->getContents(), true); + } + + public function generateEmbeddings(string $text): array + { + $response = $this->jsonRequest('POST', 'v1/embeddings', [ + 'input' => $text, + 'model' => $this->embeddingModel, + ]); + + return $response['data'][0]['embedding']; + } + + public function query(string $input, array $context): string + { + $formattedContext = implode("\n", $context); + + $response = $this->jsonRequest('POST', 'v1/chat/completions', [ + 'model' => $this->queryModel, + 'messages' => [ + [ + 'role' => 'developer', + 'content' => 'You are a helpful assistant providing search query responses. Be specific, factual and to-the-point in response. Don\'t try to converse or continue the conversation.' + ], + [ + 'role' => 'user', + 'content' => "Provide a response to the below given QUERY using the below given CONTEXT. The CONTEXT is split into parts via lines. Ignore any nonsensical lines of CONTEXT.\nQUERY: {$input}\n\nCONTEXT: {$formattedContext}", + ] + ], + ]); + + return $response['choices'][0]['message']['content'] ?? ''; + } +} diff --git a/app/Search/Queries/Services/VectorQueryService.php b/app/Search/Queries/Services/VectorQueryService.php new file mode 100644 index 00000000000..114669474a7 --- /dev/null +++ b/app/Search/Queries/Services/VectorQueryService.php @@ -0,0 +1,21 @@ +generateAndStore($this->entity); + } +} diff --git a/app/Search/Queries/TextChunker.php b/app/Search/Queries/TextChunker.php new file mode 100644 index 00000000000..73350664c54 --- /dev/null +++ b/app/Search/Queries/TextChunker.php @@ -0,0 +1,79 @@ +delimiterOrder) === 0 || $this->delimiterOrder[count($this->delimiterOrder) - 1] !== '') { + $this->delimiterOrder[] = ''; + } + + if ($this->chunkSize < 1) { + throw new InvalidArgumentException('Chunk size must be greater than 0'); + } + } + + public function chunk(string $text): array + { + $delimiter = $this->delimiterOrder[0]; + $delimiterLength = strlen($delimiter); + $lines = ($delimiter === '') ? str_split($text, $this->chunkSize) : explode($delimiter, $text); + + $cChunk = ''; // Current chunk + $cLength = 0; // Current chunk length + $chunks = []; // Chunks to return + $lDelim = ''; // Last delimiter + + foreach ($lines as $index => $line) { + $lineLength = strlen($line); + if ($cLength + $lineLength + $delimiterLength <= $this->chunkSize) { + $cChunk .= $line . $delimiter; + $cLength += $lineLength + $delimiterLength; + $lDelim = $delimiter; + } else if ($lineLength <= $this->chunkSize) { + $chunks[] = trim($cChunk, $delimiter); + $cChunk = $line . $delimiter; + $cLength = $lineLength + $delimiterLength; + $lDelim = $delimiter; + } else { + $subChunks = new static($this->chunkSize, array_slice($this->delimiterOrder, 1)); + $subDelimiter = $this->delimiterOrder[1] ?? ''; + $subDelimiterLength = strlen($subDelimiter); + foreach ($subChunks->chunk($line) as $subChunk) { + $chunkLength = strlen($subChunk); + if ($cLength + $chunkLength + $subDelimiterLength <= $this->chunkSize) { + $cChunk .= $subChunk . $subDelimiter; + $cLength += $chunkLength + $subDelimiterLength; + $lDelim = $subDelimiter; + } else { + $chunks[] = trim($cChunk, $lDelim); + $cChunk = $subChunk . $subDelimiter; + $cLength = $chunkLength + $subDelimiterLength; + $lDelim = $subDelimiter; + } + } + } + } + + if ($cChunk !== '') { + $chunks[] = trim($cChunk, $lDelim); + } + + return $chunks; + } +} diff --git a/app/Search/Queries/VectorQueryServiceProvider.php b/app/Search/Queries/VectorQueryServiceProvider.php new file mode 100644 index 00000000000..e9bd460ebf3 --- /dev/null +++ b/app/Search/Queries/VectorQueryServiceProvider.php @@ -0,0 +1,38 @@ +getServiceName(); + + if ($service === 'openai') { + return new OpenAiVectorQueryService(config('services.openai'), $this->http); + } + + throw new \Exception("No '{$service}' LLM service found"); + } + + protected static function getServiceName(): string + { + return strtolower(config('services.llm')); + } + + public static function isEnabled(): bool + { + return !empty(static::getServiceName()); + } +} diff --git a/app/Search/Queries/VectorSearchResult.php b/app/Search/Queries/VectorSearchResult.php new file mode 100644 index 00000000000..b4c27bf9df0 --- /dev/null +++ b/app/Search/Queries/VectorSearchResult.php @@ -0,0 +1,17 @@ +vectorQueryServiceProvider->get(); + $queryVector = $queryService->generateEmbeddings($query); + + // TODO - Test permissions applied + $topMatchesQuery = SearchVector::query()->select('text', 'entity_type', 'entity_id') + ->selectRaw('VEC_DISTANCE_COSINE(VEC_FROMTEXT("[' . implode(',', $queryVector) . ']"), embedding) as distance') + ->orderBy('distance', 'asc') + ->having('distance', '<', 0.6) + ->limit(10); + + $query = $this->permissions->restrictEntityRelationQuery($topMatchesQuery, 'search_vectors', 'entity_id', 'entity_type'); + $topMatches = $query->get(); + + $this->entityLoader->loadIntoRelations($topMatches->all(), 'entity', true); + + $results = []; + + foreach ($topMatches as $match) { + if ($match->relationLoaded('entity')) { + $results[] = new VectorSearchResult( + $match->getRelation('entity'), + $match->getAttribute('distance'), + $match->getAttribute('text'), + ); + } + } + + return $results; + } +} diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php index 2fce6a3d53f..9050f65f512 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Queries\QueryPopular; use BookStack\Entities\Tools\SiblingFetcher; use BookStack\Http\Controller; +use BookStack\Search\Queries\VectorSearchRunner; use Illuminate\Http\Request; class SearchController extends Controller @@ -128,7 +129,7 @@ public function searchSuggestions(Request $request) } /** - * Search siblings items in the system. + * Search sibling items in the system. */ public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher) { diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index 844e3584b20..aaee97fe747 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -6,6 +6,8 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Search\Queries\StoreEntityVectorsJob; +use BookStack\Search\Queries\VectorQueryServiceProvider; use BookStack\Util\HtmlDocument; use DOMNode; use Illuminate\Database\Eloquent\Builder; @@ -25,7 +27,7 @@ class SearchIndex public static string $softDelimiters = ".-"; public function __construct( - protected EntityProvider $entityProvider + protected EntityProvider $entityProvider, ) { } @@ -37,6 +39,10 @@ public function indexEntity(Entity $entity): void $this->deleteEntityTerms($entity); $terms = $this->entityToTermDataArray($entity); $this->insertTerms($terms); + + if (VectorQueryServiceProvider::isEnabled()) { + dispatch(new StoreEntityVectorsJob($entity)); + } } /** @@ -47,9 +53,15 @@ public function indexEntity(Entity $entity): void public function indexEntities(array $entities): void { $terms = []; + $vectorQueryEnabled = VectorQueryServiceProvider::isEnabled(); + foreach ($entities as $entity) { $entityTerms = $this->entityToTermDataArray($entity); array_push($terms, ...$entityTerms); + + if ($vectorQueryEnabled) { + dispatch(new StoreEntityVectorsJob($entity)); + } } $this->insertTerms($terms); diff --git a/database/migrations/2025_03_24_155748_create_search_vectors_table.php b/database/migrations/2025_03_24_155748_create_search_vectors_table.php new file mode 100644 index 00000000000..0ae67c2256f --- /dev/null +++ b/database/migrations/2025_03_24_155748_create_search_vectors_table.php @@ -0,0 +1,37 @@ +string('entity_type', 100); + $table->integer('entity_id'); + $table->text('text'); + + $table->index(['entity_type', 'entity_id']); + }); + + $table = DB::getTablePrefix() . 'search_vectors'; + + // TODO - Vector size might need to be dynamic + DB::statement("ALTER TABLE {$table} ADD COLUMN (embedding VECTOR(1536) NOT NULL)"); + DB::statement("ALTER TABLE {$table} ADD VECTOR INDEX (embedding) DISTANCE=cosine"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_vectors'); + } +}; diff --git a/package-lock.json b/package-lock.json index 079e397700a..86bdc05e8b0 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 151338d8c6e..637457a9369 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/index.ts b/resources/js/components/index.ts index 63e1ad0dbf7..dcb28abd760 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -44,6 +44,7 @@ export {PagePicker} from './page-picker'; export {PermissionsTable} from './permissions-table'; export {Pointer} from './pointer'; export {Popup} from './popup'; +export {QueryManager} from './query-manager'; export {SettingAppColorScheme} from './setting-app-color-scheme'; export {SettingColorPicker} from './setting-color-picker'; export {SettingHomepageControl} from './setting-homepage-control'; diff --git a/resources/js/components/query-manager.ts b/resources/js/components/query-manager.ts new file mode 100644 index 00000000000..91bd63a2293 --- /dev/null +++ b/resources/js/components/query-manager.ts @@ -0,0 +1,77 @@ +import {Component} from "./component"; + +export class QueryManager extends Component { + protected input!: HTMLTextAreaElement; + protected generatedLoading!: HTMLElement; + protected generatedDisplay!: HTMLElement; + protected contentLoading!: HTMLElement; + protected contentDisplay!: HTMLElement; + protected form!: HTMLFormElement; + protected fieldset!: HTMLFieldSetElement; + + setup() { + this.input = this.$refs.input as HTMLTextAreaElement; + this.form = this.$refs.form as HTMLFormElement; + this.fieldset = this.$refs.fieldset as HTMLFieldSetElement; + this.generatedLoading = this.$refs.generatedLoading; + this.generatedDisplay = this.$refs.generatedDisplay; + this.contentLoading = this.$refs.contentLoading; + this.contentDisplay = this.$refs.contentDisplay; + + this.setupListeners(); + + // Start lookup if a query is set + if (this.input.value.trim() !== '') { + this.runQuery(); + } + } + + protected setupListeners(): void { + // Handle form submission + this.form.addEventListener('submit', event => { + event.preventDefault(); + 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(); + } + }); + } + + protected async runQuery(): Promise { + this.contentLoading.hidden = false; + this.generatedLoading.hidden = false; + this.contentDisplay.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 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.innerHTML = JSON.parse(data).view; + this.contentLoading.hidden = true; + } else if (messageCount === 2) { + // LLM Output + this.generatedDisplay.innerText = JSON.parse(data).result; + this.generatedLoading.hidden = true; + } else { + es.close(); + break; + } + } + + this.fieldset.disabled = false; + } +} \ No newline at end of file diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts index f9eaafc3912..07f150220b4 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 diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 12fb3385f96..ff17cf52745 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -597,3 +597,29 @@ input.shortcut-input { max-width: 120px; height: auto; } + +.query-form { + display: flex; + flex-direction: row; + gap: vars.$m; + textarea { + font-size: 1.4rem; + height: 100px; + box-shadow: vars.$bs-card; + border-radius: 8px; + color: #444; + } + button { + align-self: start; + margin: 0; + font-size: 1.6rem; + } + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} \ No newline at end of file diff --git a/resources/views/search/query.blade.php b/resources/views/search/query.blade.php new file mode 100644 index 00000000000..3293c0ddc9b --- /dev/null +++ b/resources/views/search/query.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+

Start a Query

+
+
+ + +
+
+
+ +
+

Generated Response

+ +

+ + When you run a query, the relevant content found & shown below will be used to help generate a smart machine generated response. + +

+
+ + +
+

Relevant Content

+ +
+
+

+ Start a query to find relevant matching content. + The items shown here reflect those used to help provide the above response. +

+
+
+
+
+@stop diff --git a/routes/web.php b/routes/web.php index ea3efe1ac77..d27855100f3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use BookStack\Http\Middleware\VerifyCsrfToken; use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; +use BookStack\Search\Queries\QueryController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; use BookStack\Sorting as SortingControllers; @@ -196,6 +197,11 @@ Route::get('/search/entity-selector-templates', [SearchController::class, 'templatesForSelector']); Route::get('/search/suggest', [SearchController::class, 'searchSuggestions']); + // Queries + Route::get('/query', [QueryController::class, 'show']); + Route::get('/query/run', [QueryController::class, 'run']); // TODO - Development only, remove + Route::post('/query', [QueryController::class, 'run']); + // User Search Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']); diff --git a/tests/Search/TextChunkerTest.php b/tests/Search/TextChunkerTest.php new file mode 100644 index 00000000000..c742c4a6402 --- /dev/null +++ b/tests/Search/TextChunkerTest.php @@ -0,0 +1,47 @@ +chunk('123456789'); + + $this->assertEquals(['123', '456', '789'], $chunks); + } + + public function test_chunk_size_must_be_greater_than_zero() + { + $this->expectException(\InvalidArgumentException::class); + $chunker = new TextChunker(-5, []); + } + + public function test_it_works_through_given_delimiters() + { + $chunker = new TextChunker(5, ['-', '.', '']); + $chunks = $chunker->chunk('12-3456.789abcdefg'); + + $this->assertEquals(['12', '3456', '789ab', 'cdefg'], $chunks); + } + + public function test_it_attempts_to_pack_chunks() + { + $chunker = new TextChunker(8, [' ', '']); + $chunks = $chunker->chunk('123 456 789 abc def'); + + $this->assertEquals(['123 456', '789 abc', 'def'], $chunks); + } + + public function test_it_attempts_to_pack_using_subchunks() + { + $chunker = new TextChunker(8, [' ', '-', '']); + $chunks = $chunker->chunk('123 456-789abc'); + + $this->assertEquals(['123 456', '789abc'], $chunks); + } +}