@open-mercato/search 0.4.2-canary-c02407ff85
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/AGENTS.md +678 -0
- package/build.mjs +92 -0
- package/dist/di.js +157 -0
- package/dist/di.js.map +7 -0
- package/dist/fulltext/drivers/index.js +21 -0
- package/dist/fulltext/drivers/index.js.map +7 -0
- package/dist/fulltext/drivers/meilisearch/index.js +320 -0
- package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
- package/dist/fulltext/index.js +7 -0
- package/dist/fulltext/index.js.map +7 -0
- package/dist/fulltext/types.js +1 -0
- package/dist/fulltext/types.js.map +7 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +7 -0
- package/dist/indexer/index.js +8 -0
- package/dist/indexer/index.js.map +7 -0
- package/dist/indexer/search-indexer.js +848 -0
- package/dist/indexer/search-indexer.js.map +7 -0
- package/dist/indexer/subscribers/delete.js +41 -0
- package/dist/indexer/subscribers/delete.js.map +7 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/debug.js.map +7 -0
- package/dist/lib/fallback-presenter.js +107 -0
- package/dist/lib/fallback-presenter.js.map +7 -0
- package/dist/lib/field-policy.js +75 -0
- package/dist/lib/field-policy.js.map +7 -0
- package/dist/lib/index.js +19 -0
- package/dist/lib/index.js.map +7 -0
- package/dist/lib/merger.js +93 -0
- package/dist/lib/merger.js.map +7 -0
- package/dist/lib/presenter-enricher.js +192 -0
- package/dist/lib/presenter-enricher.js.map +7 -0
- package/dist/modules/search/acl.js +14 -0
- package/dist/modules/search/acl.js.map +7 -0
- package/dist/modules/search/ai-tools.js +284 -0
- package/dist/modules/search/ai-tools.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
- package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/route.js +246 -0
- package/dist/modules/search/api/embeddings/route.js.map +7 -0
- package/dist/modules/search/api/index/route.js +245 -0
- package/dist/modules/search/api/index/route.js.map +7 -0
- package/dist/modules/search/api/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/reindex/route.js +332 -0
- package/dist/modules/search/api/reindex/route.js.map +7 -0
- package/dist/modules/search/api/search/global/route.js +100 -0
- package/dist/modules/search/api/search/global/route.js.map +7 -0
- package/dist/modules/search/api/search/route.js +101 -0
- package/dist/modules/search/api/search/route.js.map +7 -0
- package/dist/modules/search/api/settings/fulltext/route.js +55 -0
- package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
- package/dist/modules/search/api/settings/global-search/route.js +80 -0
- package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
- package/dist/modules/search/api/settings/route.js +118 -0
- package/dist/modules/search/api/settings/route.js.map +7 -0
- package/dist/modules/search/api/settings/vector-store/route.js +77 -0
- package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.js +10 -0
- package/dist/modules/search/backend/config/search/page.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.meta.js +24 -0
- package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
- package/dist/modules/search/cli.js +698 -0
- package/dist/modules/search/cli.js.map +7 -0
- package/dist/modules/search/di.js +32 -0
- package/dist/modules/search/di.js.map +7 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/index.js +9 -0
- package/dist/modules/search/frontend/index.js.map +7 -0
- package/dist/modules/search/frontend/utils.js +41 -0
- package/dist/modules/search/frontend/utils.js.map +7 -0
- package/dist/modules/search/i18n/de.json +61 -0
- package/dist/modules/search/i18n/en.json +72 -0
- package/dist/modules/search/i18n/es.json +61 -0
- package/dist/modules/search/i18n/pl.json +61 -0
- package/dist/modules/search/index.js +11 -0
- package/dist/modules/search/index.js.map +7 -0
- package/dist/modules/search/lib/auto-indexing.js +29 -0
- package/dist/modules/search/lib/auto-indexing.js.map +7 -0
- package/dist/modules/search/lib/embedding-config.js +131 -0
- package/dist/modules/search/lib/embedding-config.js.map +7 -0
- package/dist/modules/search/lib/global-search-config.js +45 -0
- package/dist/modules/search/lib/global-search-config.js.map +7 -0
- package/dist/modules/search/lib/reindex-lock.js +99 -0
- package/dist/modules/search/lib/reindex-lock.js.map +7 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
- package/dist/modules/search/subscribers/vector_delete.js +58 -0
- package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
- package/dist/modules/search/subscribers/vector_purge.js +142 -0
- package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
- package/dist/modules/search/subscribers/vector_upsert.js +58 -0
- package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
- package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
- package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
- package/dist/modules/search/workers/vector-index.worker.js +234 -0
- package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
- package/dist/queue/fulltext-indexing.js +15 -0
- package/dist/queue/fulltext-indexing.js.map +7 -0
- package/dist/queue/index.js +3 -0
- package/dist/queue/index.js.map +7 -0
- package/dist/queue/vector-indexing.js +15 -0
- package/dist/queue/vector-indexing.js.map +7 -0
- package/dist/service.js +286 -0
- package/dist/service.js.map +7 -0
- package/dist/strategies/fulltext.strategy.js +116 -0
- package/dist/strategies/fulltext.strategy.js.map +7 -0
- package/dist/strategies/index.js +12 -0
- package/dist/strategies/index.js.map +7 -0
- package/dist/strategies/token.strategy.js +80 -0
- package/dist/strategies/token.strategy.js.map +7 -0
- package/dist/strategies/vector.strategy.js +137 -0
- package/dist/strategies/vector.strategy.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/dist/vector/drivers/chromadb/index.js +44 -0
- package/dist/vector/drivers/chromadb/index.js.map +7 -0
- package/dist/vector/drivers/index.js +9 -0
- package/dist/vector/drivers/index.js.map +7 -0
- package/dist/vector/drivers/pgvector/index.js +509 -0
- package/dist/vector/drivers/pgvector/index.js.map +7 -0
- package/dist/vector/drivers/qdrant/index.js +44 -0
- package/dist/vector/drivers/qdrant/index.js.map +7 -0
- package/dist/vector/index.js +4 -0
- package/dist/vector/index.js.map +7 -0
- package/dist/vector/lib/vector-logs.js +33 -0
- package/dist/vector/lib/vector-logs.js.map +7 -0
- package/dist/vector/services/checksum.js +20 -0
- package/dist/vector/services/checksum.js.map +7 -0
- package/dist/vector/services/embedding.js +222 -0
- package/dist/vector/services/embedding.js.map +7 -0
- package/dist/vector/services/index.js +4 -0
- package/dist/vector/services/index.js.map +7 -0
- package/dist/vector/services/vector-index.service.js +960 -0
- package/dist/vector/services/vector-index.service.js.map +7 -0
- package/dist/vector/types/pg.d.js +1 -0
- package/dist/vector/types/pg.d.js.map +7 -0
- package/dist/vector/types.js +75 -0
- package/dist/vector/types.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +142 -0
- package/src/__tests__/queue.test.ts +148 -0
- package/src/__tests__/service.test.ts +345 -0
- package/src/__tests__/workers.test.ts +319 -0
- package/src/di.ts +291 -0
- package/src/fulltext/drivers/index.ts +41 -0
- package/src/fulltext/drivers/meilisearch/index.ts +410 -0
- package/src/fulltext/index.ts +13 -0
- package/src/fulltext/types.ts +115 -0
- package/src/index.ts +36 -0
- package/src/indexer/index.ts +13 -0
- package/src/indexer/search-indexer.ts +1141 -0
- package/src/indexer/subscribers/delete.ts +49 -0
- package/src/lib/debug.ts +46 -0
- package/src/lib/fallback-presenter.ts +106 -0
- package/src/lib/field-policy.ts +169 -0
- package/src/lib/index.ts +13 -0
- package/src/lib/merger.ts +159 -0
- package/src/lib/presenter-enricher.ts +323 -0
- package/src/modules/search/README.md +694 -0
- package/src/modules/search/acl.ts +10 -0
- package/src/modules/search/ai-tools.ts +467 -0
- package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
- package/src/modules/search/api/embeddings/route.ts +304 -0
- package/src/modules/search/api/index/route.ts +297 -0
- package/src/modules/search/api/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/reindex/route.ts +419 -0
- package/src/modules/search/api/search/global/route.ts +120 -0
- package/src/modules/search/api/search/route.ts +121 -0
- package/src/modules/search/api/settings/fulltext/route.ts +82 -0
- package/src/modules/search/api/settings/global-search/route.ts +91 -0
- package/src/modules/search/api/settings/route.ts +187 -0
- package/src/modules/search/api/settings/vector-store/route.ts +105 -0
- package/src/modules/search/backend/config/search/page.meta.ts +22 -0
- package/src/modules/search/backend/config/search/page.tsx +12 -0
- package/src/modules/search/cli.ts +818 -0
- package/src/modules/search/di.ts +50 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
- package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
- package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
- package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
- package/src/modules/search/frontend/index.ts +3 -0
- package/src/modules/search/frontend/utils.ts +82 -0
- package/src/modules/search/i18n/de.json +61 -0
- package/src/modules/search/i18n/en.json +72 -0
- package/src/modules/search/i18n/es.json +61 -0
- package/src/modules/search/i18n/pl.json +61 -0
- package/src/modules/search/index.ts +9 -0
- package/src/modules/search/lib/auto-indexing.ts +35 -0
- package/src/modules/search/lib/embedding-config.ts +161 -0
- package/src/modules/search/lib/global-search-config.ts +69 -0
- package/src/modules/search/lib/reindex-lock.ts +201 -0
- package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
- package/src/modules/search/subscribers/vector_delete.ts +75 -0
- package/src/modules/search/subscribers/vector_purge.ts +161 -0
- package/src/modules/search/subscribers/vector_upsert.ts +75 -0
- package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
- package/src/modules/search/workers/vector-index.worker.ts +292 -0
- package/src/queue/fulltext-indexing.ts +87 -0
- package/src/queue/index.ts +2 -0
- package/src/queue/vector-indexing.ts +66 -0
- package/src/service.ts +397 -0
- package/src/strategies/fulltext.strategy.ts +155 -0
- package/src/strategies/index.ts +17 -0
- package/src/strategies/token.strategy.ts +153 -0
- package/src/strategies/vector.strategy.ts +234 -0
- package/src/types.ts +38 -0
- package/src/vector/drivers/chromadb/index.ts +49 -0
- package/src/vector/drivers/index.ts +4 -0
- package/src/vector/drivers/pgvector/index.ts +627 -0
- package/src/vector/drivers/qdrant/index.ts +49 -0
- package/src/vector/index.ts +3 -0
- package/src/vector/lib/vector-logs.ts +46 -0
- package/src/vector/services/checksum.ts +18 -0
- package/src/vector/services/embedding.ts +275 -0
- package/src/vector/services/index.ts +3 -0
- package/src/vector/services/vector-index.service.ts +1234 -0
- package/src/vector/types/pg.d.ts +1 -0
- package/src/vector/types.ts +220 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
6
|
+
import * as LucideIcons from 'lucide-react'
|
|
7
|
+
import type { LucideIcon } from 'lucide-react'
|
|
8
|
+
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
9
|
+
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
10
|
+
import type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
11
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
12
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
13
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
14
|
+
import { fetchHybridSearchResults } from '../utils'
|
|
15
|
+
|
|
16
|
+
type Row = {
|
|
17
|
+
entityId: string
|
|
18
|
+
recordId: string
|
|
19
|
+
source: string
|
|
20
|
+
score: number | null
|
|
21
|
+
url: string | null
|
|
22
|
+
presenter: SearchResult['presenter'] | null
|
|
23
|
+
links: SearchResult['links'] | null
|
|
24
|
+
metadata: Record<string, unknown> | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MIN_QUERY_LENGTH = 2
|
|
28
|
+
const ALL_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']
|
|
29
|
+
|
|
30
|
+
type Translator = (
|
|
31
|
+
key: string,
|
|
32
|
+
fallbackOrParams?: string | Record<string, string | number>,
|
|
33
|
+
params?: Record<string, string | number>
|
|
34
|
+
) => string
|
|
35
|
+
|
|
36
|
+
function createColumns(t: Translator): ColumnDef<Row>[] {
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
id: 'title',
|
|
40
|
+
header: () => t('search.table.columns.result', 'Result'),
|
|
41
|
+
cell: ({ row }) => {
|
|
42
|
+
const item = row.original
|
|
43
|
+
const title = resolveRowTitle(item)
|
|
44
|
+
const iconName = item.presenter?.icon
|
|
45
|
+
const Icon = iconName ? resolveIcon(iconName) : null
|
|
46
|
+
const typeLabel = formatEntityId(item.entityId)
|
|
47
|
+
const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata)
|
|
48
|
+
const links = normalizeLinks(item.links)
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col">
|
|
51
|
+
<div className="flex items-start gap-3">
|
|
52
|
+
{Icon ? <Icon className="mt-0.5 h-5 w-5 text-muted-foreground" /> : null}
|
|
53
|
+
<div className="flex flex-col gap-1">
|
|
54
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
55
|
+
<span className="font-medium whitespace-normal break-all">{title}</span>
|
|
56
|
+
<span className="rounded border border-muted-foreground/40 px-2 py-0.5 text-xs text-muted-foreground">
|
|
57
|
+
{typeLabel}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
{snapshot ? (
|
|
61
|
+
<span className="text-sm text-muted-foreground whitespace-normal break-words">{snapshot}</span>
|
|
62
|
+
) : null}
|
|
63
|
+
{links.length ? (
|
|
64
|
+
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
65
|
+
{links.map((link) => (
|
|
66
|
+
<span
|
|
67
|
+
key={`${item.entityId}:${item.recordId}:${link.href}`}
|
|
68
|
+
className={cn(
|
|
69
|
+
'rounded-full border px-2 py-0.5 text-xs',
|
|
70
|
+
link.kind === 'primary'
|
|
71
|
+
? 'border-primary text-primary'
|
|
72
|
+
: 'border-muted-foreground/40 text-muted-foreground'
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{link.label ?? link.href}
|
|
76
|
+
</span>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
meta: { priority: 1 },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'source',
|
|
89
|
+
header: () => t('search.table.columns.source', 'Source'),
|
|
90
|
+
cell: ({ row }) => {
|
|
91
|
+
const source = row.original.source
|
|
92
|
+
const colorClass = getStrategyColorClass(source)
|
|
93
|
+
return (
|
|
94
|
+
<span className={cn('rounded px-2 py-0.5 text-xs font-medium', colorClass)}>
|
|
95
|
+
{source}
|
|
96
|
+
</span>
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
meta: { priority: 2 },
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'score',
|
|
103
|
+
header: () => t('search.table.columns.score', 'Score'),
|
|
104
|
+
cell: ({ row }) => <span>{row.original.score != null ? row.original.score.toFixed(2) : '—'}</span>,
|
|
105
|
+
meta: { priority: 2 },
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getStrategyColorClass(strategy: string): string {
|
|
111
|
+
switch (strategy) {
|
|
112
|
+
case 'fulltext':
|
|
113
|
+
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
|
114
|
+
case 'vector':
|
|
115
|
+
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
|
116
|
+
case 'tokens':
|
|
117
|
+
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
118
|
+
default:
|
|
119
|
+
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeLinks(links?: Row['links']): { href: string; label?: string; kind?: string }[] {
|
|
124
|
+
if (!Array.isArray(links)) return []
|
|
125
|
+
return links.filter((link) => typeof link?.href === 'string') as Array<{ href: string; label?: string; kind?: string }>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toPascalCase(input: string): string {
|
|
129
|
+
return input
|
|
130
|
+
.split(/[-_ ]+/)
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
133
|
+
.join('')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveIcon(name?: string): LucideIcon | null {
|
|
137
|
+
if (!name) return null
|
|
138
|
+
const key = toPascalCase(name)
|
|
139
|
+
const candidate = (LucideIcons as Record<string, unknown>)[key]
|
|
140
|
+
if (typeof candidate === 'function') {
|
|
141
|
+
return candidate as LucideIcon
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function humanizeSegment(segment: string): string {
|
|
147
|
+
return segment
|
|
148
|
+
.split(/[_-]+/)
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
151
|
+
.join(' ')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatEntityId(entityId: string): string {
|
|
155
|
+
if (!entityId.includes(':')) return humanizeSegment(entityId)
|
|
156
|
+
const [module, entity] = entityId.split(':')
|
|
157
|
+
const moduleLabel = humanizeSegment(module)
|
|
158
|
+
const entityLabel = humanizeSegment(entity)
|
|
159
|
+
return `${moduleLabel} · ${entityLabel}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveRowTitle(row: Row): string {
|
|
163
|
+
const presenterTitle = row.presenter?.title
|
|
164
|
+
if (typeof presenterTitle === 'string') {
|
|
165
|
+
const trimmed = presenterTitle.trim()
|
|
166
|
+
if (trimmed.length) return trimmed
|
|
167
|
+
}
|
|
168
|
+
return row.recordId
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractSnapshot(metadata: Record<string, unknown> | null): string | null {
|
|
172
|
+
if (!metadata) return null
|
|
173
|
+
const candidateKeys = ['snapshot', 'summary', 'description', 'body', 'content', 'note']
|
|
174
|
+
for (const key of candidateKeys) {
|
|
175
|
+
const value = metadata[key]
|
|
176
|
+
if (typeof value === 'string') {
|
|
177
|
+
const trimmed = value.trim()
|
|
178
|
+
if (trimmed.length) return trimmed
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function pickPrimaryLink(row: Row): string | null {
|
|
185
|
+
if (row.url) return row.url
|
|
186
|
+
const links = normalizeLinks(row.links)
|
|
187
|
+
if (!links.length) return null
|
|
188
|
+
const primary = links.find((link) => link.kind === 'primary')
|
|
189
|
+
return (primary ?? links[0]).href
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeErrorMessage(input: unknown, fallback?: string): string | null {
|
|
193
|
+
const fallbackMessage = typeof fallback === 'string' && fallback.trim().length ? fallback.trim() : null
|
|
194
|
+
let message: string | null = null
|
|
195
|
+
if (typeof input === 'string') {
|
|
196
|
+
message = input
|
|
197
|
+
} else if (input instanceof Error && typeof input.message === 'string') {
|
|
198
|
+
message = input.message
|
|
199
|
+
}
|
|
200
|
+
if (message) {
|
|
201
|
+
const trimmed = message.trim()
|
|
202
|
+
if (trimmed.length) {
|
|
203
|
+
const sanitized = trimmed.replace(/^\[[^\]]+\]\s*/, '').trim()
|
|
204
|
+
if (sanitized.length) return sanitized
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return fallbackMessage
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type HybridSearchTableProps = {
|
|
211
|
+
/** Show strategy selector checkboxes (default: false - hidden from regular users) */
|
|
212
|
+
showStrategySelector?: boolean
|
|
213
|
+
/** Show source column in results (default: false - hidden from regular users) */
|
|
214
|
+
showSourceColumn?: boolean
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function HybridSearchTable({
|
|
218
|
+
showStrategySelector = false,
|
|
219
|
+
showSourceColumn = false,
|
|
220
|
+
}: HybridSearchTableProps = {}) {
|
|
221
|
+
const router = useRouter()
|
|
222
|
+
const t = useT()
|
|
223
|
+
const [searchValue, setSearchValue] = React.useState('')
|
|
224
|
+
const [rows, setRows] = React.useState<Row[]>([])
|
|
225
|
+
const [page, setPage] = React.useState(1)
|
|
226
|
+
const [loading, setLoading] = React.useState(false)
|
|
227
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
228
|
+
const [timing, setTiming] = React.useState<number | null>(null)
|
|
229
|
+
const [strategiesUsed, setStrategiesUsed] = React.useState<string[]>([])
|
|
230
|
+
const [enabledStrategies, setEnabledStrategies] = React.useState<Set<SearchStrategyId>>(
|
|
231
|
+
new Set(ALL_STRATEGIES)
|
|
232
|
+
)
|
|
233
|
+
const debounceRef = React.useRef<number | null>(null)
|
|
234
|
+
const abortRef = React.useRef<AbortController | null>(null)
|
|
235
|
+
const columns = React.useMemo(() => {
|
|
236
|
+
const allColumns = createColumns(t)
|
|
237
|
+
if (!showSourceColumn) {
|
|
238
|
+
return allColumns.filter((col) => col.id !== 'source')
|
|
239
|
+
}
|
|
240
|
+
return allColumns
|
|
241
|
+
}, [t, showSourceColumn])
|
|
242
|
+
|
|
243
|
+
const toggleStrategy = React.useCallback((strategy: SearchStrategyId) => {
|
|
244
|
+
setEnabledStrategies((prev) => {
|
|
245
|
+
const next = new Set(prev)
|
|
246
|
+
if (next.has(strategy)) {
|
|
247
|
+
next.delete(strategy)
|
|
248
|
+
} else {
|
|
249
|
+
next.add(strategy)
|
|
250
|
+
}
|
|
251
|
+
return next
|
|
252
|
+
})
|
|
253
|
+
}, [])
|
|
254
|
+
|
|
255
|
+
const openRow = React.useCallback(
|
|
256
|
+
(row: Row) => {
|
|
257
|
+
const href = pickPrimaryLink(row)
|
|
258
|
+
if (!href) return
|
|
259
|
+
router.push(href)
|
|
260
|
+
},
|
|
261
|
+
[router]
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
React.useEffect(() => {
|
|
265
|
+
const trimmed = searchValue.trim()
|
|
266
|
+
abortRef.current?.abort()
|
|
267
|
+
if (debounceRef.current) {
|
|
268
|
+
window.clearTimeout(debounceRef.current)
|
|
269
|
+
debounceRef.current = null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
273
|
+
setRows([])
|
|
274
|
+
setTiming(null)
|
|
275
|
+
setStrategiesUsed([])
|
|
276
|
+
setError(null)
|
|
277
|
+
setLoading(false)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (enabledStrategies.size === 0) {
|
|
282
|
+
setRows([])
|
|
283
|
+
setTiming(null)
|
|
284
|
+
setStrategiesUsed([])
|
|
285
|
+
setError(t('search.table.errors.noSources', 'Select at least one search source'))
|
|
286
|
+
setLoading(false)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const controller = new AbortController()
|
|
291
|
+
abortRef.current = controller
|
|
292
|
+
setLoading(true)
|
|
293
|
+
|
|
294
|
+
debounceRef.current = window.setTimeout(async () => {
|
|
295
|
+
try {
|
|
296
|
+
const data = await fetchHybridSearchResults(trimmed, {
|
|
297
|
+
limit: 50,
|
|
298
|
+
strategies: Array.from(enabledStrategies),
|
|
299
|
+
signal: controller.signal,
|
|
300
|
+
})
|
|
301
|
+
const mapped = data.results.map<Row>((item) => ({
|
|
302
|
+
entityId: item.entityId,
|
|
303
|
+
recordId: item.recordId,
|
|
304
|
+
source: item.source,
|
|
305
|
+
score: typeof item.score === 'number' ? item.score : null,
|
|
306
|
+
url: item.url ?? null,
|
|
307
|
+
presenter: item.presenter ?? null,
|
|
308
|
+
links: item.links ?? null,
|
|
309
|
+
metadata: (item.metadata as Record<string, unknown> | null) ?? null,
|
|
310
|
+
}))
|
|
311
|
+
setRows(mapped)
|
|
312
|
+
setTiming(data.timing)
|
|
313
|
+
setStrategiesUsed(data.strategiesUsed)
|
|
314
|
+
const message = data.error ? normalizeErrorMessage(data.error, t('search.table.errors.searchFailed', 'Search failed')) : null
|
|
315
|
+
setError(message ?? null)
|
|
316
|
+
setPage(1)
|
|
317
|
+
} catch (err: unknown) {
|
|
318
|
+
if (controller.signal.aborted) return
|
|
319
|
+
if ((err as { name?: string })?.name === 'AbortError') return
|
|
320
|
+
setError(normalizeErrorMessage(err, t('search.table.errors.searchFailed', 'Search failed')))
|
|
321
|
+
setRows([])
|
|
322
|
+
setTiming(null)
|
|
323
|
+
setStrategiesUsed([])
|
|
324
|
+
} finally {
|
|
325
|
+
if (!controller.signal.aborted) setLoading(false)
|
|
326
|
+
}
|
|
327
|
+
}, 250)
|
|
328
|
+
|
|
329
|
+
return () => {
|
|
330
|
+
controller.abort()
|
|
331
|
+
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
|
332
|
+
}
|
|
333
|
+
}, [searchValue, enabledStrategies, t])
|
|
334
|
+
|
|
335
|
+
React.useEffect(() => {
|
|
336
|
+
if (!error) return
|
|
337
|
+
flash(error, 'error')
|
|
338
|
+
}, [error])
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className="flex w-full flex-col gap-4">
|
|
342
|
+
{/* Source Selector - only shown when showStrategySelector is true */}
|
|
343
|
+
{showStrategySelector && (
|
|
344
|
+
<div className="flex flex-wrap items-center gap-4 rounded-lg border bg-muted/50 p-3">
|
|
345
|
+
<span className="text-sm font-medium text-muted-foreground">
|
|
346
|
+
{t('search.table.sources', 'Sources:')}
|
|
347
|
+
</span>
|
|
348
|
+
{ALL_STRATEGIES.map((strategy) => (
|
|
349
|
+
<label key={strategy} className="flex cursor-pointer items-center gap-2">
|
|
350
|
+
<input
|
|
351
|
+
type="checkbox"
|
|
352
|
+
className="size-4 rounded border-gray-300"
|
|
353
|
+
checked={enabledStrategies.has(strategy)}
|
|
354
|
+
onChange={() => toggleStrategy(strategy)}
|
|
355
|
+
/>
|
|
356
|
+
<span
|
|
357
|
+
className={cn(
|
|
358
|
+
'rounded px-2 py-0.5 text-xs font-medium',
|
|
359
|
+
getStrategyColorClass(strategy)
|
|
360
|
+
)}
|
|
361
|
+
>
|
|
362
|
+
{strategy}
|
|
363
|
+
</span>
|
|
364
|
+
</label>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Stats Bar */}
|
|
370
|
+
{timing !== null && rows.length > 0 && (
|
|
371
|
+
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
372
|
+
<span>{rows.length} {t('search.table.stats.results', 'results')}</span>
|
|
373
|
+
<span>{timing}ms</span>
|
|
374
|
+
{/* Only show sources when strategy selector is visible */}
|
|
375
|
+
{showStrategySelector && strategiesUsed.length > 0 && (
|
|
376
|
+
<span>
|
|
377
|
+
{t('search.table.stats.sources', 'Sources:')} {strategiesUsed.join(', ')}
|
|
378
|
+
</span>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{/* Error Alert */}
|
|
384
|
+
{error ? (
|
|
385
|
+
<div
|
|
386
|
+
role="alert"
|
|
387
|
+
className="w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
|
388
|
+
>
|
|
389
|
+
{error}
|
|
390
|
+
</div>
|
|
391
|
+
) : null}
|
|
392
|
+
|
|
393
|
+
{/* Data Table */}
|
|
394
|
+
<DataTable<Row>
|
|
395
|
+
title={t('search.table.title', 'Search')}
|
|
396
|
+
columns={columns}
|
|
397
|
+
data={rows}
|
|
398
|
+
searchValue={searchValue}
|
|
399
|
+
onSearchChange={(value) => {
|
|
400
|
+
setSearchValue(value)
|
|
401
|
+
setPage(1)
|
|
402
|
+
}}
|
|
403
|
+
searchPlaceholder={t('search.table.searchPlaceholder', 'Search across all strategies...')}
|
|
404
|
+
isLoading={loading}
|
|
405
|
+
pagination={{ page, pageSize: rows.length || 1, total: rows.length, totalPages: 1, onPageChange: setPage }}
|
|
406
|
+
onRowClick={(row) => openRow(row)}
|
|
407
|
+
rowActions={(row) => {
|
|
408
|
+
const primaryHref = pickPrimaryLink(row)
|
|
409
|
+
if (!primaryHref) return null
|
|
410
|
+
return <RowActions items={[{ label: t('search.table.actions.open', 'Open'), href: primaryHref }]} />
|
|
411
|
+
}}
|
|
412
|
+
embedded
|
|
413
|
+
/>
|
|
414
|
+
</div>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export default HybridSearchTable
|