@mframework/layer-search 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -0
- package/app/components/README.md +3 -0
- package/app/components/features/autocomplete.vue +63 -0
- package/app/components/features/searchkitSearch.vue +115 -0
- package/app/components/filters/filters.vue +0 -0
- package/app/components/molecules/SearchInput.vue +39 -0
- package/app/components/molecules/pagination.vue +21 -0
- package/app/components/molecules/resultList.vue +48 -0
- package/app/components/search.vue +87 -0
- package/app/composables/adapter/mock.ts +26 -0
- package/app/composables/adapter/types.ts +21 -0
- package/app/composables/bridges/instantsearch.ts +21 -0
- package/app/composables/bridges/react.ts +39 -0
- package/app/composables/bridges/searchkit-server.ts +51 -0
- package/app/composables/bridges/searchkit.ts +88 -0
- package/app/composables/bridges/vue.ts +38 -0
- package/app/composables/cli.ts +70 -0
- package/app/composables/config/schema.ts +16 -0
- package/app/composables/config.ts +20 -0
- package/app/composables/core/Facets.ts +9 -0
- package/app/composables/core/Filters.ts +13 -0
- package/app/composables/core/Pipeline.ts +20 -0
- package/app/composables/core/QueryBuilder.ts +27 -0
- package/app/composables/core/SearchContext.ts +54 -0
- package/app/composables/core/SearchManager.ts +26 -0
- package/app/composables/events.ts +5 -0
- package/app/composables/index.ts +12 -0
- package/app/composables/module.ts +48 -0
- package/app/composables/types/api/global-search.ts +8 -0
- package/app/composables/types.d.ts +12 -0
- package/app/composables/useSearchkit.ts +218 -0
- package/app/composables/utils/health.ts +13 -0
- package/app/composables/utils/normalizers.ts +6 -0
- package/app/pages/results.vue +85 -0
- package/app/plugins/instantsearch.js +35 -0
- package/app/plugins/search.js +103 -0
- package/app/plugins/searchClient.ts +108 -0
- package/app/utils/env.ts +28 -0
- package/app/utils/search/client.ts +53 -0
- package/nuxt.config.ts +11 -0
- package/package.json +36 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
export type SearchHit = Record<string, any>
|
|
3
|
+
import { useNuxtApp } from 'nuxt/app'
|
|
4
|
+
|
|
5
|
+
export function useSearchkit() {
|
|
6
|
+
const nuxt = useNuxtApp() as any
|
|
7
|
+
const client: any = nuxt.$searchClient
|
|
8
|
+
const indexName: string = (nuxt.$searchIndexName as string) || 'default'
|
|
9
|
+
const helpers: any = nuxt.$searchHelpers || {}
|
|
10
|
+
|
|
11
|
+
const query = ref('')
|
|
12
|
+
const hits = ref<SearchHit[]>([])
|
|
13
|
+
const total = ref(0)
|
|
14
|
+
const loading = ref(false)
|
|
15
|
+
const page = ref(1)
|
|
16
|
+
const perPage = ref(12)
|
|
17
|
+
const sortBy = ref<string | null>(null)
|
|
18
|
+
const facets = ref<Record<string, any>>({})
|
|
19
|
+
const geo = ref<{ lat?: number; lon?: number; distanceKm?: number }>({})
|
|
20
|
+
const semantic = ref(false)
|
|
21
|
+
const ranking = ref<'relevance' | 'newest' | 'popularity' | 'custom'>('relevance')
|
|
22
|
+
|
|
23
|
+
async function search() {
|
|
24
|
+
if (!client || typeof client.search !== 'function') {
|
|
25
|
+
throw new Error('Search client is not available or does not implement `search`')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
loading.value = true
|
|
29
|
+
const from = (page.value - 1) * perPage.value
|
|
30
|
+
|
|
31
|
+
// Build params — aim for compatibility with Searchkit/Elasticsearch via instantsearch client.
|
|
32
|
+
const params: any = {
|
|
33
|
+
params: {
|
|
34
|
+
q: query.value || '*',
|
|
35
|
+
size: perPage.value,
|
|
36
|
+
from,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sorting strategies
|
|
41
|
+
if (ranking.value === 'newest') {
|
|
42
|
+
params.params.sort = 'created_at:desc'
|
|
43
|
+
} else if (ranking.value === 'popularity') {
|
|
44
|
+
params.params.sort = 'popularity:desc'
|
|
45
|
+
} else if (sortBy.value) {
|
|
46
|
+
params.params.sort = sortBy.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Geo filter: translate into an Elasticsearch geo_distance filter in body
|
|
50
|
+
if (geo.value.lat && geo.value.lon && geo.value.distanceKm) {
|
|
51
|
+
params.body = params.body || {}
|
|
52
|
+
params.body.query = params.body.query || { bool: { must: [{ match_all: {} }], filter: [] } }
|
|
53
|
+
const filter = { geo_distance: { distance: `${geo.value.distanceKm}km`, location: { lat: geo.value.lat, lon: geo.value.lon } } }
|
|
54
|
+
params.body.query.bool.filter.push(filter)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Semantic hint flag — some backends will honour this to run semantic/rerank
|
|
58
|
+
if (semantic.value) {
|
|
59
|
+
params.params.semantic = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const results = await client.search([{ indexName, ...params }])
|
|
64
|
+
const res = results && results[0]
|
|
65
|
+
if (!res) {
|
|
66
|
+
hits.value = []
|
|
67
|
+
total.value = 0
|
|
68
|
+
facets.value = {}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Best-effort parsing for common instantsearch-style responses
|
|
73
|
+
const rawHits = (res.hits && res.hits.hits) || (res.hits && res.hits) || res.hits
|
|
74
|
+
let parsedHits: any[] = []
|
|
75
|
+
if (Array.isArray(rawHits)) {
|
|
76
|
+
// ES style hits.hits
|
|
77
|
+
parsedHits = rawHits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
|
|
78
|
+
} else if (res.hits && res.hits.hits) {
|
|
79
|
+
parsedHits = res.hits.hits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
|
|
80
|
+
} else if (res.hits) {
|
|
81
|
+
parsedHits = res.hits
|
|
82
|
+
} else {
|
|
83
|
+
parsedHits = []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
total.value = (res.hits && (res.hits.total?.value ?? res.hits.total)) || res.nbHits || 0
|
|
87
|
+
|
|
88
|
+
// Aggregations / facets parsing (normalize via plugin helper when available)
|
|
89
|
+
const rawAggs = res.aggregations || res.facets || {}
|
|
90
|
+
if (helpers && typeof helpers.mapAggregations === 'function') {
|
|
91
|
+
try {
|
|
92
|
+
facets.value = helpers.mapAggregations(rawAggs)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
facets.value = rawAggs
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
facets.value = rawAggs
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If semantic rerank is enabled, attempt to call the plugin helper to reorder results.
|
|
101
|
+
if (semantic.value && helpers && typeof helpers.semanticRerank === 'function') {
|
|
102
|
+
try {
|
|
103
|
+
const reranked = await helpers.semanticRerank(parsedHits, query.value)
|
|
104
|
+
hits.value = Array.isArray(reranked) ? reranked : parsedHits
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// fallback to parsedHits on error
|
|
107
|
+
hits.value = parsedHits
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
hits.value = parsedHits
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
loading.value = false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function autocomplete(input: string, limit = 6) {
|
|
118
|
+
if (!client) return []
|
|
119
|
+
try {
|
|
120
|
+
// Use a small search request to act as suggestions
|
|
121
|
+
const params = {
|
|
122
|
+
params: { q: input || '*', size: limit },
|
|
123
|
+
}
|
|
124
|
+
const result = await client.search([{ indexName, ...params }])
|
|
125
|
+
const r = result && result[0]
|
|
126
|
+
const suggestionHits = (r && ((r.hits && r.hits.hits) || r.hits)) || []
|
|
127
|
+
return Array.isArray(suggestionHits)
|
|
128
|
+
? suggestionHits.map((h: any) => (h._source ? h._source : h))
|
|
129
|
+
: []
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return []
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setPage(p: number) {
|
|
136
|
+
page.value = p
|
|
137
|
+
return search()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function setPerPage(n: number) {
|
|
141
|
+
perPage.value = n
|
|
142
|
+
page.value = 1
|
|
143
|
+
return search()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setSort(sort: string | null) {
|
|
147
|
+
sortBy.value = sort
|
|
148
|
+
return search()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setGeo(lat: number, lon: number, distanceKm = 50) {
|
|
152
|
+
geo.value = { lat, lon, distanceKm }
|
|
153
|
+
page.value = 1
|
|
154
|
+
return search()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setSemantic(enabled: boolean) {
|
|
158
|
+
semantic.value = enabled
|
|
159
|
+
return search()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / perPage.value)))
|
|
163
|
+
|
|
164
|
+
// Provide a normalized facets structure for consumers: { facetName: [{ key, doc_count }, ...] }
|
|
165
|
+
const normalizedFacets = computed(() => {
|
|
166
|
+
const out: Record<string, Array<{ key: string; doc_count: number }>> = {}
|
|
167
|
+
try {
|
|
168
|
+
for (const k of Object.keys(facets.value || {})) {
|
|
169
|
+
const v = (facets.value as any)[k]
|
|
170
|
+
if (!v) {
|
|
171
|
+
out[k] = []
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(v)) {
|
|
175
|
+
out[k] = v.map((b: any) => ({ key: b.key ?? b.value ?? String(b), doc_count: b.doc_count ?? b.count ?? 0 }))
|
|
176
|
+
} else if (v.buckets) {
|
|
177
|
+
out[k] = v.buckets.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
|
|
178
|
+
} else if (v.terms) {
|
|
179
|
+
out[k] = v.terms.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
|
|
180
|
+
} else {
|
|
181
|
+
// generic object — try to map entries
|
|
182
|
+
out[k] = Object.keys(v).map((kk) => ({ key: kk, doc_count: (v as any)[kk] }))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return {}
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
// state
|
|
193
|
+
query,
|
|
194
|
+
hits,
|
|
195
|
+
total,
|
|
196
|
+
loading,
|
|
197
|
+
page,
|
|
198
|
+
perPage,
|
|
199
|
+
sortBy,
|
|
200
|
+
facets,
|
|
201
|
+
geo,
|
|
202
|
+
semantic,
|
|
203
|
+
ranking,
|
|
204
|
+
totalPages,
|
|
205
|
+
|
|
206
|
+
// actions
|
|
207
|
+
search,
|
|
208
|
+
autocomplete,
|
|
209
|
+
setPage,
|
|
210
|
+
setPerPage,
|
|
211
|
+
setSort,
|
|
212
|
+
setGeo,
|
|
213
|
+
setSemantic,
|
|
214
|
+
normalizedFacets,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default useSearchkit
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MeeoviSearchAdapter } from '../adapter/types'
|
|
2
|
+
|
|
3
|
+
export async function checkAdapterHealth(adapter?: MeeoviSearchAdapter) {
|
|
4
|
+
if (!adapter) return { ok: false, error: 'no adapter' }
|
|
5
|
+
try {
|
|
6
|
+
const res = await adapter.search({ term: '', page: 1, pageSize: 1, filters: {} } as any)
|
|
7
|
+
return { ok: true, total: res?.total ?? null }
|
|
8
|
+
} catch (e: any) {
|
|
9
|
+
return { ok: false, error: e?.message || String(e) }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default checkAdapterHealth
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="resultsPage">
|
|
3
|
+
<ais-instant-search :search-client="searchClient" :index-name="indexName">
|
|
4
|
+
<header class="search-header">
|
|
5
|
+
<ais-search-box />
|
|
6
|
+
<ais-stats />
|
|
7
|
+
<ais-sort-by :items="sortingOptions" />
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<div class="search-body">
|
|
11
|
+
<aside class="filters">
|
|
12
|
+
<ais-refinement-list attribute="category" />
|
|
13
|
+
<ais-refinement-list attribute="brand" />
|
|
14
|
+
<ais-range-input attribute="price" />
|
|
15
|
+
<ais-rating-menu attribute="rating_rounded" />
|
|
16
|
+
</aside>
|
|
17
|
+
|
|
18
|
+
<main class="results">
|
|
19
|
+
<ais-hits>
|
|
20
|
+
<template #item="{ item }">
|
|
21
|
+
<article @click="openResult(item)" class="hit">
|
|
22
|
+
<h3><ais-highlight attribute="title" :hit="item" /></h3>
|
|
23
|
+
<p><ais-snippet attribute="plot" :hit="item" /></p>
|
|
24
|
+
</article>
|
|
25
|
+
</template>
|
|
26
|
+
</ais-hits>
|
|
27
|
+
|
|
28
|
+
<ais-pagination />
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
</ais-instant-search>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup>
|
|
36
|
+
import { ref } from 'vue'
|
|
37
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
38
|
+
import { useRuntimeConfig } from '#imports'
|
|
39
|
+
import Client from '@searchkit/instantsearch-client'
|
|
40
|
+
import { getSearchClient, getIndexName } from '../utils/search/client'
|
|
41
|
+
|
|
42
|
+
import 'instantsearch.css/themes/satellite-min.css'
|
|
43
|
+
|
|
44
|
+
// initialize search client using existing helper (falls back safely)
|
|
45
|
+
let searchClient
|
|
46
|
+
let indexName = 'default'
|
|
47
|
+
try {
|
|
48
|
+
searchClient = getSearchClient()
|
|
49
|
+
indexName = getIndexName()
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// fallback placeholder
|
|
52
|
+
// eslint-disable-next-line no-console
|
|
53
|
+
console.warn('Search client unavailable:', e.message || e)
|
|
54
|
+
searchClient = { _client: 'searchkit-fallback' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const router = useRouter()
|
|
58
|
+
const route = useRoute()
|
|
59
|
+
|
|
60
|
+
function openResult(item) {
|
|
61
|
+
const id = item._id ?? item.id ?? ''
|
|
62
|
+
const title = item.title ?? item.name ?? ''
|
|
63
|
+
router.push({ path: route.path, query: { ...route.query, id, title } })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sortingOptions = [
|
|
67
|
+
{ value: indexName, label: 'Featured' },
|
|
68
|
+
{ value: `${indexName}:price:asc`, label: 'Price: Low to High' },
|
|
69
|
+
{ value: `${indexName}:price:desc`, label: 'Price: High to Low' },
|
|
70
|
+
{ value: `${indexName}:rating:desc`, label: 'Rating: High to Low' }
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
definePageMeta({ layout: 'nolive' })
|
|
74
|
+
useHead({ title: 'Search Results' })
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<style scoped>
|
|
78
|
+
.search-body {
|
|
79
|
+
display: flex;
|
|
80
|
+
gap: 1rem;
|
|
81
|
+
}
|
|
82
|
+
.filters { width: 260px }
|
|
83
|
+
.results { flex: 1 }
|
|
84
|
+
.hit { padding: 0.75rem; border-bottom: 1px solid #eee }
|
|
85
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { h } from 'vue'
|
|
2
|
+
import { defineNuxtPlugin } from '#imports'
|
|
3
|
+
import InstantSearch from 'vue-instantsearch/vue3/es'
|
|
4
|
+
|
|
5
|
+
export default defineNuxtPlugin(nuxtApp => {
|
|
6
|
+
// Register the original InstantSearch plugin (registers other components)
|
|
7
|
+
nuxtApp.vueApp.use(InstantSearch)
|
|
8
|
+
// Capture the original registered component (if the plugin registered it)
|
|
9
|
+
const OriginalInstantSearch = nuxtApp.vueApp.component('ais-instant-search') || InstantSearch
|
|
10
|
+
|
|
11
|
+
// Override `ais-instant-search` with a small wrapper that sets
|
|
12
|
+
// future.preserveSharedStateOnUnmount = true by default to silence
|
|
13
|
+
// the deprecation warning and adopt the new behavior.
|
|
14
|
+
const AisInstantSearchWrapper = {
|
|
15
|
+
name: 'AisInstantSearch',
|
|
16
|
+
inheritAttrs: false,
|
|
17
|
+
setup(_, { attrs, slots }) {
|
|
18
|
+
const mergedAttrs = {
|
|
19
|
+
...attrs,
|
|
20
|
+
future: {
|
|
21
|
+
preserveSharedStateOnUnmount: true,
|
|
22
|
+
...(attrs.future || {})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
26
|
+
// helpful debug when running locally
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.debug('[instantsearch] merged future options', mergedAttrs.future)
|
|
29
|
+
}
|
|
30
|
+
return () => h(OriginalInstantSearch, mergedAttrs, slots)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
nuxtApp.vueApp.component('ais-instant-search', AisInstantSearchWrapper)
|
|
35
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { defineNuxtPlugin } from '#imports'
|
|
2
|
+
import { getSearchClient, getIndexName } from '../utils/search/client'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
5
|
+
let searchClient
|
|
6
|
+
let indexName
|
|
7
|
+
try {
|
|
8
|
+
searchClient = getSearchClient()
|
|
9
|
+
indexName = getIndexName()
|
|
10
|
+
} catch (e) {
|
|
11
|
+
// fallback placeholder to avoid build/runtime crash when not configured
|
|
12
|
+
// Provide a clearer validation message to help users wire env vars
|
|
13
|
+
// eslint-disable-next-line no-console
|
|
14
|
+
console.warn('Search client not configured:', e.message || e)
|
|
15
|
+
// Helpful runtime guidance
|
|
16
|
+
// eslint-disable-next-line no-console
|
|
17
|
+
console.info('Search layer requires configuring SEARCHKIT_HOST or SEARCHKIT_HOSTNAME/SEARCHKIT_PORT (or their NUXT_PUBLIC_ variants). Example:')
|
|
18
|
+
// eslint-disable-next-line no-console
|
|
19
|
+
console.info(' SEARCHKIT_HOST=https://search.example.com')
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.info(' or:')
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.info(' SEARCHKIT_PROTOCOL=https')
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.info(' SEARCHKIT_HOSTNAME=search.example.com')
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.info(' SEARCHKIT_PORT=9200')
|
|
28
|
+
searchClient = { _client: 'searchkit-fallback' }
|
|
29
|
+
indexName = 'default'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper: normalize aggregations/ facets into { name: buckets[] }
|
|
33
|
+
function mapAggregations(aggs) {
|
|
34
|
+
const mapped = {}
|
|
35
|
+
if (!aggs) return mapped
|
|
36
|
+
for (const key of Object.keys(aggs)) {
|
|
37
|
+
const a = aggs[key]
|
|
38
|
+
if (!a) continue
|
|
39
|
+
if (Array.isArray(a)) mapped[key] = a
|
|
40
|
+
else if (a.buckets) mapped[key] = a.buckets
|
|
41
|
+
else if (a.terms) mapped[key] = a.terms
|
|
42
|
+
else mapped[key] = a
|
|
43
|
+
}
|
|
44
|
+
return mapped
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Basic suggestion helper that performs a small search to return candidate documents
|
|
48
|
+
async function suggest(q, opts = {}) {
|
|
49
|
+
if (!searchClient || typeof searchClient.search !== 'function') return []
|
|
50
|
+
const size = opts.size || 6
|
|
51
|
+
const params = { params: { q: q || '*', size } }
|
|
52
|
+
try {
|
|
53
|
+
const resArr = await searchClient.search([{ indexName, ...params }])
|
|
54
|
+
const res = resArr && resArr[0]
|
|
55
|
+
const hits = (res && ((res.hits && res.hits.hits) || res.hits)) || []
|
|
56
|
+
return Array.isArray(hits) ? hits.map((h) => (h._source ? h._source : h)) : []
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.warn('Suggest failed', e && e.message ? e.message : e)
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Server-side semantic reranking: call embeddings API if configured, otherwise noop.
|
|
65
|
+
// Expects an embeddings API that can accept { query, docs } and return either
|
|
66
|
+
// { ranked: [ids...] } or { scores: [number...] } aligned with docs.
|
|
67
|
+
async function semanticRerank(hitsArray, queryStr) {
|
|
68
|
+
const url = process.env.NUXT_PUBLIC_EMBEDDINGS_API_URL || process.env.EMBEDDINGS_API_URL
|
|
69
|
+
const key = process.env.NUXT_PUBLIC_EMBEDDINGS_API_KEY || process.env.EMBEDDINGS_API_KEY
|
|
70
|
+
if (!url || !key) return hitsArray
|
|
71
|
+
try {
|
|
72
|
+
const docs = hitsArray.slice(0, 100).map((h) => ({ id: h._meta || h.id || h._id, text: h.title || h.name || h.description || '' }))
|
|
73
|
+
const body = { query: queryStr, docs }
|
|
74
|
+
const resp = await (globalThis.fetch || fetch)(url, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
})
|
|
79
|
+
if (!resp.ok) return hitsArray
|
|
80
|
+
const json = await resp.json()
|
|
81
|
+
if (Array.isArray(json.ranked)) {
|
|
82
|
+
const idToHit = new Map(hitsArray.map((h) => [(h._meta || h.id || h._id), h]))
|
|
83
|
+
const ordered = json.ranked.map((id) => idToHit.get(id)).filter(Boolean)
|
|
84
|
+
const remaining = hitsArray.filter((h) => !ordered.includes(h))
|
|
85
|
+
return [...ordered, ...remaining]
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(json.scores)) {
|
|
88
|
+
const paired = hitsArray.map((h, i) => ({ h, s: json.scores[i] ?? 0 }))
|
|
89
|
+
paired.sort((a, b) => b.s - a.s)
|
|
90
|
+
return paired.map((p) => p.h)
|
|
91
|
+
}
|
|
92
|
+
return hitsArray
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// eslint-disable-next-line no-console
|
|
95
|
+
console.warn('semanticRerank failed', e && e.message ? e.message : e)
|
|
96
|
+
return hitsArray
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
nuxtApp.provide('searchClient', searchClient)
|
|
101
|
+
nuxtApp.provide('searchIndexName', indexName)
|
|
102
|
+
nuxtApp.provide('searchHelpers', { suggest, mapAggregations, semanticRerank })
|
|
103
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const config = useRuntimeConfig() as any
|
|
5
|
+
const endpoint: string | undefined = config.search?.endpoint || process.env.SEARCH_ENDPOINT
|
|
6
|
+
const indexName: string = config.search?.indexName || process.env.SEARCH_INDEX_NAME || 'default'
|
|
7
|
+
|
|
8
|
+
// Simple helpers that consumers may rely on
|
|
9
|
+
const helpers = {
|
|
10
|
+
mapAggregations(aggs: any) {
|
|
11
|
+
return aggs || {}
|
|
12
|
+
},
|
|
13
|
+
async semanticRerank(hits: any[], _query: string) {
|
|
14
|
+
// no-op rerank: return input
|
|
15
|
+
return hits
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// HTTP-backed ES-compatible client when endpoint is configured
|
|
20
|
+
const httpClient = endpoint
|
|
21
|
+
? {
|
|
22
|
+
async search(requests: Array<any>) {
|
|
23
|
+
const base = endpoint.replace(/\/+$/, '')
|
|
24
|
+
return Promise.all(
|
|
25
|
+
requests.map(async (req: any) => {
|
|
26
|
+
const idx = req.indexName || indexName
|
|
27
|
+
// Build a basic ES query body from params
|
|
28
|
+
let body: any = {}
|
|
29
|
+
|
|
30
|
+
// If params contains q/size/from, map to simple query_string
|
|
31
|
+
if (req.params) {
|
|
32
|
+
const p = req.params
|
|
33
|
+
const q = (p.q || (p.params && p.params.q)) || '*'
|
|
34
|
+
const size = p.size || (p.params && p.params.size) || 10
|
|
35
|
+
const from = p.from || 0
|
|
36
|
+
if (!req.body) body = {}
|
|
37
|
+
if (q && q !== '*') {
|
|
38
|
+
body.query = { query_string: { query: q } }
|
|
39
|
+
} else if (!req.body) {
|
|
40
|
+
body.query = { match_all: {} }
|
|
41
|
+
}
|
|
42
|
+
body.size = size
|
|
43
|
+
body.from = from
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (req.body) {
|
|
47
|
+
body = { ...body, ...req.body }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// fetch with retries and exponential backoff
|
|
51
|
+
async function fetchWithRetry(url: string, init: RequestInit, retries = 2, backoff = 200): Promise<any> {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(url, init)
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text().catch(() => '')
|
|
56
|
+
if (retries > 0) {
|
|
57
|
+
await new Promise((r) => setTimeout(r, backoff))
|
|
58
|
+
return fetchWithRetry(url, init, retries - 1, backoff * 2)
|
|
59
|
+
}
|
|
60
|
+
return { hits: [], error: `HTTP ${res.status}: ${text}` }
|
|
61
|
+
}
|
|
62
|
+
return await res.json()
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (retries > 0) {
|
|
65
|
+
await new Promise((r) => setTimeout(r, backoff))
|
|
66
|
+
return fetchWithRetry(url, init, retries - 1, backoff * 2)
|
|
67
|
+
}
|
|
68
|
+
return { hits: [], error: String(err) }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const resJson = await fetchWithRetry(`${base}/${encodeURIComponent(idx)}/_search`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
})
|
|
77
|
+
// Ensure a consistent shape on error
|
|
78
|
+
if (!resJson) return { hits: [] }
|
|
79
|
+
return resJson
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
: null
|
|
85
|
+
|
|
86
|
+
// In-memory fallback client for environments without an endpoint
|
|
87
|
+
const stubClient = {
|
|
88
|
+
async search(requests: Array<any>) {
|
|
89
|
+
return requests.map((req: any) => {
|
|
90
|
+
const q = (req.params && (req.params.q || (req.params.params && req.params.params.q))) || '*'
|
|
91
|
+
const hits = [
|
|
92
|
+
{ _source: { title: q && q !== '*' ? `Result for ${q}` : 'Sample result', description: 'Stubbed result' } },
|
|
93
|
+
]
|
|
94
|
+
return { hits: { hits }, aggregations: {}, nbHits: hits.length }
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const client = httpClient || stubClient
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
provide: {
|
|
103
|
+
$searchClient: client,
|
|
104
|
+
$searchIndexName: indexName,
|
|
105
|
+
$searchHelpers: helpers,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
})
|
package/app/utils/env.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
let shared: any = null
|
|
2
|
+
try {
|
|
3
|
+
// Try to use the centralized shared helper at runtime when available
|
|
4
|
+
// Use require to avoid TypeScript resolving the module at compile-time for this layer project
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
6
|
+
shared = require('../../../shared/app/utils/env')
|
|
7
|
+
} catch (e) {
|
|
8
|
+
shared = null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getEnv(key: string, fallback?: string): string | undefined {
|
|
12
|
+
if (shared && typeof shared.getEnv === 'function') return shared.getEnv(key, fallback)
|
|
13
|
+
if (!key) return fallback
|
|
14
|
+
const direct = process.env[key]
|
|
15
|
+
if (direct !== undefined) return direct
|
|
16
|
+
const publicKey = `NUXT_PUBLIC_${key}`
|
|
17
|
+
if (process.env[publicKey] !== undefined) return process.env[publicKey]
|
|
18
|
+
return fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getEnvBool(key: string, fallback = false): boolean {
|
|
22
|
+
const v = getEnv(key)
|
|
23
|
+
if (v === undefined) return fallback
|
|
24
|
+
const low = String(v).toLowerCase()
|
|
25
|
+
return ['1', 'true', 'yes', 'on'].includes(low)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default { getEnv, getEnvBool }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Lightweight helper to obtain a search client and index name for InstantSearch
|
|
2
|
+
// This version NEVER throws — it returns null when search is not configured.
|
|
3
|
+
|
|
4
|
+
import type { SearchClient } from 'instantsearch.js'
|
|
5
|
+
import { getEnv } from '../env'
|
|
6
|
+
|
|
7
|
+
export function getIndexName(): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env.NUXT_PUBLIC_SEARCH_INDEX ||
|
|
10
|
+
process.env.SEARCH_INDEX ||
|
|
11
|
+
'default'
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildHostFromParts(): string | null {
|
|
16
|
+
const explicit = getEnv('SEARCHKIT_HOST')
|
|
17
|
+
if (explicit) return explicit
|
|
18
|
+
|
|
19
|
+
const protocol = (getEnv('SEARCHKIT_PROTOCOL') || 'http').replace(/:\/\//, '')
|
|
20
|
+
const hostname = getEnv('SEARCHKIT_HOSTNAME')
|
|
21
|
+
const port = getEnv('SEARCHKIT_PORT')
|
|
22
|
+
|
|
23
|
+
if (!hostname) return null
|
|
24
|
+
|
|
25
|
+
return `${protocol}://${hostname}${port ? `:${port}` : ''}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getSearchClient(): SearchClient | null {
|
|
29
|
+
const host = buildHostFromParts()
|
|
30
|
+
if (!host) {
|
|
31
|
+
console.warn('[search] Searchkit host not configured — search disabled')
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let createClient: any
|
|
36
|
+
try {
|
|
37
|
+
createClient = require('@searchkit/instantsearch-client')
|
|
38
|
+
} catch {
|
|
39
|
+
console.warn('[search] @searchkit/instantsearch-client not installed — search disabled')
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const opts: any = { host }
|
|
45
|
+
const apiKey = getEnv('SEARCHKIT_API_KEY')
|
|
46
|
+
if (apiKey) opts.apiKey = apiKey
|
|
47
|
+
|
|
48
|
+
return createClient(opts)
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
console.warn('[search] Failed to create Searchkit client:', e?.message || e)
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|