@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,50 @@
|
|
|
1
|
+
import { asValue } from 'awilix'
|
|
2
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'
|
|
4
|
+
import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'
|
|
5
|
+
import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'
|
|
6
|
+
import type { Queue } from '@open-mercato/queue'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register search module dependencies.
|
|
10
|
+
*
|
|
11
|
+
* This registers:
|
|
12
|
+
* - vectorEmbeddingService: EmbeddingService for creating embeddings
|
|
13
|
+
* - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)
|
|
14
|
+
* - vectorIndexQueue: Queue for vector indexing jobs
|
|
15
|
+
* - fulltextIndexQueue: Queue for fulltext indexing jobs
|
|
16
|
+
*
|
|
17
|
+
* Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,
|
|
18
|
+
* which is registered in the main search module DI (packages/search/src/di.ts).
|
|
19
|
+
*/
|
|
20
|
+
export function register(container: AppContainer) {
|
|
21
|
+
const embeddingService = new EmbeddingService()
|
|
22
|
+
const drivers = [
|
|
23
|
+
createPgVectorDriver(),
|
|
24
|
+
createChromaDbDriver(),
|
|
25
|
+
createQdrantDriver(),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
// Create queues based on environment strategy
|
|
29
|
+
const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
|
|
30
|
+
const queueConnection = queueStrategy === 'async'
|
|
31
|
+
? { connection: { url: process.env.REDIS_URL || process.env.QUEUE_REDIS_URL } }
|
|
32
|
+
: undefined
|
|
33
|
+
|
|
34
|
+
const vectorIndexQueue: Queue<VectorIndexJobPayload> = createVectorIndexingQueue(
|
|
35
|
+
queueStrategy,
|
|
36
|
+
queueConnection,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const fulltextIndexQueue: Queue<FulltextIndexJobPayload> = createFulltextIndexingQueue(
|
|
40
|
+
queueStrategy,
|
|
41
|
+
queueConnection,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
container.register({
|
|
45
|
+
vectorEmbeddingService: asValue(embeddingService),
|
|
46
|
+
vectorDrivers: asValue(drivers),
|
|
47
|
+
vectorIndexQueue: asValue(vectorIndexQueue),
|
|
48
|
+
fulltextIndexQueue: asValue(fulltextIndexQueue),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import {
|
|
6
|
+
Search,
|
|
7
|
+
Loader2,
|
|
8
|
+
Zap,
|
|
9
|
+
User,
|
|
10
|
+
Users,
|
|
11
|
+
Building,
|
|
12
|
+
StickyNote,
|
|
13
|
+
Briefcase,
|
|
14
|
+
CheckSquare,
|
|
15
|
+
FileText,
|
|
16
|
+
Mail,
|
|
17
|
+
Phone,
|
|
18
|
+
Calendar,
|
|
19
|
+
Clock,
|
|
20
|
+
Star,
|
|
21
|
+
Tag,
|
|
22
|
+
Flag,
|
|
23
|
+
Heart,
|
|
24
|
+
Bookmark,
|
|
25
|
+
Package,
|
|
26
|
+
Truck,
|
|
27
|
+
ShoppingCart,
|
|
28
|
+
CreditCard,
|
|
29
|
+
DollarSign,
|
|
30
|
+
Target,
|
|
31
|
+
Award,
|
|
32
|
+
Trophy,
|
|
33
|
+
Rocket,
|
|
34
|
+
Lightbulb,
|
|
35
|
+
MessageSquare,
|
|
36
|
+
Bell,
|
|
37
|
+
Settings,
|
|
38
|
+
Globe,
|
|
39
|
+
MapPin,
|
|
40
|
+
Link,
|
|
41
|
+
Folder,
|
|
42
|
+
Database,
|
|
43
|
+
Activity,
|
|
44
|
+
} from 'lucide-react'
|
|
45
|
+
import type { LucideIcon } from 'lucide-react'
|
|
46
|
+
import { Dialog, DialogContent } from '@open-mercato/ui/primitives/dialog'
|
|
47
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
48
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
49
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
50
|
+
import type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
51
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
52
|
+
import { fetchGlobalSearchResults } from '../utils'
|
|
53
|
+
|
|
54
|
+
const MIN_QUERY_LENGTH = 2
|
|
55
|
+
|
|
56
|
+
/** Default strategies used when none are configured */
|
|
57
|
+
const DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']
|
|
58
|
+
|
|
59
|
+
function normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {
|
|
60
|
+
if (!Array.isArray(links)) return []
|
|
61
|
+
return links.filter((link) => typeof link?.href === 'string')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pickPrimaryLink(result: SearchResult): string | null {
|
|
65
|
+
if (result.url) return result.url
|
|
66
|
+
const links = normalizeLinks(result.links)
|
|
67
|
+
if (!links.length) return null
|
|
68
|
+
const primary = links.find((link) => link.kind === 'primary')
|
|
69
|
+
return (primary ?? links[0]).href
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function humanizeSegment(segment: string): string {
|
|
73
|
+
return segment
|
|
74
|
+
.split(/[_-]+/)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
77
|
+
.join(' ')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ICON_MAP: Record<string, LucideIcon> = {
|
|
81
|
+
bolt: Zap,
|
|
82
|
+
zap: Zap,
|
|
83
|
+
user: User,
|
|
84
|
+
users: Users,
|
|
85
|
+
building: Building,
|
|
86
|
+
'sticky-note': StickyNote,
|
|
87
|
+
briefcase: Briefcase,
|
|
88
|
+
'check-square': CheckSquare,
|
|
89
|
+
'file-text': FileText,
|
|
90
|
+
mail: Mail,
|
|
91
|
+
phone: Phone,
|
|
92
|
+
calendar: Calendar,
|
|
93
|
+
clock: Clock,
|
|
94
|
+
star: Star,
|
|
95
|
+
tag: Tag,
|
|
96
|
+
flag: Flag,
|
|
97
|
+
heart: Heart,
|
|
98
|
+
bookmark: Bookmark,
|
|
99
|
+
package: Package,
|
|
100
|
+
truck: Truck,
|
|
101
|
+
'shopping-cart': ShoppingCart,
|
|
102
|
+
'credit-card': CreditCard,
|
|
103
|
+
'dollar-sign': DollarSign,
|
|
104
|
+
target: Target,
|
|
105
|
+
award: Award,
|
|
106
|
+
trophy: Trophy,
|
|
107
|
+
rocket: Rocket,
|
|
108
|
+
lightbulb: Lightbulb,
|
|
109
|
+
'message-square': MessageSquare,
|
|
110
|
+
bell: Bell,
|
|
111
|
+
settings: Settings,
|
|
112
|
+
globe: Globe,
|
|
113
|
+
'map-pin': MapPin,
|
|
114
|
+
link: Link,
|
|
115
|
+
folder: Folder,
|
|
116
|
+
database: Database,
|
|
117
|
+
activity: Activity,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveIcon(name?: string): LucideIcon | null {
|
|
121
|
+
if (!name) return null
|
|
122
|
+
return ICON_MAP[name.toLowerCase()] ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatEntityId(entityId: string): string {
|
|
126
|
+
if (!entityId.includes(':')) return humanizeSegment(entityId)
|
|
127
|
+
const [module, entity] = entityId.split(':')
|
|
128
|
+
return `${humanizeSegment(module)} · ${humanizeSegment(entity)}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type GlobalSearchDialogProps = {
|
|
132
|
+
/** Whether embedding provider is configured for vector search */
|
|
133
|
+
embeddingConfigured: boolean
|
|
134
|
+
/** Message to show when embedding is not configured */
|
|
135
|
+
missingConfigMessage: string
|
|
136
|
+
/** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */
|
|
137
|
+
enabledStrategies?: SearchStrategyId[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function GlobalSearchDialog({
|
|
141
|
+
embeddingConfigured,
|
|
142
|
+
missingConfigMessage,
|
|
143
|
+
enabledStrategies: propStrategies,
|
|
144
|
+
}: GlobalSearchDialogProps) {
|
|
145
|
+
const router = useRouter()
|
|
146
|
+
const [open, setOpen] = React.useState(false)
|
|
147
|
+
const [query, setQuery] = React.useState('')
|
|
148
|
+
const [results, setResults] = React.useState<SearchResult[]>([])
|
|
149
|
+
const [loading, setLoading] = React.useState(false)
|
|
150
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
151
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
|
152
|
+
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
153
|
+
const abortRef = React.useRef<AbortController | null>(null)
|
|
154
|
+
const t = useT()
|
|
155
|
+
|
|
156
|
+
// Use configured strategies or fall back to defaults
|
|
157
|
+
const enabledStrategies = React.useMemo(() => {
|
|
158
|
+
if (propStrategies && propStrategies.length > 0) {
|
|
159
|
+
return propStrategies
|
|
160
|
+
}
|
|
161
|
+
return DEFAULT_STRATEGIES
|
|
162
|
+
}, [propStrategies])
|
|
163
|
+
|
|
164
|
+
const resetState = React.useCallback(() => {
|
|
165
|
+
setQuery('')
|
|
166
|
+
setResults([])
|
|
167
|
+
setError(null)
|
|
168
|
+
setSelectedIndex(0)
|
|
169
|
+
setLoading(false)
|
|
170
|
+
}, [])
|
|
171
|
+
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
if (!open) {
|
|
174
|
+
resetState()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const handler = (event: KeyboardEvent) => {
|
|
178
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
window.addEventListener('keydown', handler)
|
|
183
|
+
return () => window.removeEventListener('keydown', handler)
|
|
184
|
+
}, [open, resetState])
|
|
185
|
+
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
const shortcut = (event: KeyboardEvent) => {
|
|
188
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
189
|
+
event.preventDefault()
|
|
190
|
+
setOpen((prev) => !prev)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
window.addEventListener('keydown', shortcut)
|
|
194
|
+
return () => window.removeEventListener('keydown', shortcut)
|
|
195
|
+
}, [])
|
|
196
|
+
|
|
197
|
+
React.useEffect(() => {
|
|
198
|
+
if (!open) return
|
|
199
|
+
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)
|
|
200
|
+
return () => clearTimeout(focusTimer)
|
|
201
|
+
}, [open])
|
|
202
|
+
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
if (!open) return
|
|
205
|
+
|
|
206
|
+
abortRef.current?.abort()
|
|
207
|
+
if (query.trim().length < MIN_QUERY_LENGTH) {
|
|
208
|
+
setResults([])
|
|
209
|
+
setError(null)
|
|
210
|
+
setLoading(false)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const controller = new AbortController()
|
|
215
|
+
abortRef.current = controller
|
|
216
|
+
setLoading(true)
|
|
217
|
+
|
|
218
|
+
const handle = setTimeout(async () => {
|
|
219
|
+
try {
|
|
220
|
+
const data = await fetchGlobalSearchResults(query, {
|
|
221
|
+
limit: 10,
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
})
|
|
224
|
+
setResults(data.results)
|
|
225
|
+
setError(data.error ?? null)
|
|
226
|
+
setSelectedIndex(0)
|
|
227
|
+
} catch (err: unknown) {
|
|
228
|
+
if (controller.signal.aborted) return
|
|
229
|
+
const abortError = err as { name?: string }
|
|
230
|
+
if (abortError?.name === 'AbortError') return
|
|
231
|
+
setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))
|
|
232
|
+
setResults([])
|
|
233
|
+
} finally {
|
|
234
|
+
if (!controller.signal.aborted) setLoading(false)
|
|
235
|
+
}
|
|
236
|
+
}, 220)
|
|
237
|
+
|
|
238
|
+
return () => {
|
|
239
|
+
clearTimeout(handle)
|
|
240
|
+
controller.abort()
|
|
241
|
+
}
|
|
242
|
+
}, [open, query, enabledStrategies, t])
|
|
243
|
+
|
|
244
|
+
const openResult = React.useCallback((result: SearchResult | undefined) => {
|
|
245
|
+
if (!result) return
|
|
246
|
+
const href = pickPrimaryLink(result)
|
|
247
|
+
if (!href) return
|
|
248
|
+
router.push(href)
|
|
249
|
+
setOpen(false)
|
|
250
|
+
}, [router])
|
|
251
|
+
|
|
252
|
+
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
253
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
254
|
+
event.preventDefault()
|
|
255
|
+
openResult(results[selectedIndex])
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (event.key === 'ArrowDown') {
|
|
259
|
+
event.preventDefault()
|
|
260
|
+
setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (event.key === 'ArrowUp') {
|
|
264
|
+
event.preventDefault()
|
|
265
|
+
setSelectedIndex((prev) => {
|
|
266
|
+
if (!results.length) return 0
|
|
267
|
+
return prev <= 0 ? results.length - 1 : prev - 1
|
|
268
|
+
})
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (event.key === 'Escape') {
|
|
272
|
+
event.preventDefault()
|
|
273
|
+
setOpen(false)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (event.key === 'Enter') {
|
|
277
|
+
event.preventDefault()
|
|
278
|
+
const target = results[selectedIndex]
|
|
279
|
+
openResult(target)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
}, [results, selectedIndex, openResult])
|
|
283
|
+
|
|
284
|
+
// Check if vector search is enabled but not configured
|
|
285
|
+
const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error
|
|
286
|
+
|
|
287
|
+
// Check if selected result has a navigable link
|
|
288
|
+
const selectedResult = results[selectedIndex]
|
|
289
|
+
const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<>
|
|
293
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(true)} className="hidden sm:inline-flex items-center gap-2">
|
|
294
|
+
<Search className="h-4 w-4" />
|
|
295
|
+
<span>{t('search.dialog.actions.search')}</span>
|
|
296
|
+
<span className="ml-2 rounded border px-1 text-xs text-muted-foreground">⌘K</span>
|
|
297
|
+
</Button>
|
|
298
|
+
<Button
|
|
299
|
+
type="button"
|
|
300
|
+
variant="ghost"
|
|
301
|
+
size="icon"
|
|
302
|
+
className="sm:hidden"
|
|
303
|
+
onClick={() => setOpen(true)}
|
|
304
|
+
aria-label={t('search.dialog.actions.openGlobalSearch')}
|
|
305
|
+
>
|
|
306
|
+
<Search className="h-4 w-4" />
|
|
307
|
+
</Button>
|
|
308
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
309
|
+
<DialogContent className="max-w-xl p-0" aria-describedby="global-search-description">
|
|
310
|
+
<span id="global-search-description" className="sr-only">
|
|
311
|
+
{t('search.dialog.instructions')}
|
|
312
|
+
</span>
|
|
313
|
+
<div className="flex flex-col gap-3 border-b px-4 pb-3 pt-4">
|
|
314
|
+
<div className="flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring">
|
|
315
|
+
<Search className="h-4 w-4 text-muted-foreground" />
|
|
316
|
+
<TypedInput
|
|
317
|
+
ref={inputRef}
|
|
318
|
+
value={query}
|
|
319
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
320
|
+
onKeyDown={handleKeyDown}
|
|
321
|
+
placeholder={t('search.dialog.input.placeholder')}
|
|
322
|
+
className="border-none px-0 shadow-none focus-visible:ring-0"
|
|
323
|
+
autoFocus
|
|
324
|
+
/>
|
|
325
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{error ? (
|
|
329
|
+
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
|
330
|
+
) : null}
|
|
331
|
+
{showVectorWarning ? (
|
|
332
|
+
<p className="rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200">{missingConfigMessage}</p>
|
|
333
|
+
) : null}
|
|
334
|
+
</div>
|
|
335
|
+
<div className="max-h-96 overflow-y-auto px-2 pb-3">
|
|
336
|
+
{results.length === 0 && !loading && !error ? (
|
|
337
|
+
<div className="px-4 py-6 text-sm text-muted-foreground">
|
|
338
|
+
{query.trim().length < MIN_QUERY_LENGTH
|
|
339
|
+
? t('search.dialog.empty.hint')
|
|
340
|
+
: t('search.dialog.empty.none')}
|
|
341
|
+
</div>
|
|
342
|
+
) : null}
|
|
343
|
+
<ul className="flex flex-col">
|
|
344
|
+
{results.map((result, index) => {
|
|
345
|
+
const presenter = result.presenter
|
|
346
|
+
const isActive = index === selectedIndex
|
|
347
|
+
const hasLink = pickPrimaryLink(result) !== null
|
|
348
|
+
const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null
|
|
349
|
+
return (
|
|
350
|
+
<li key={`${result.entityId}:${result.recordId}`}>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
onClick={() => openResult(result)}
|
|
354
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
355
|
+
className={cn(
|
|
356
|
+
'w-full rounded-lg px-4 py-3 text-left transition border',
|
|
357
|
+
isActive
|
|
358
|
+
? 'border-primary bg-primary/10 text-foreground shadow-sm'
|
|
359
|
+
: 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',
|
|
360
|
+
!hasLink && 'opacity-60'
|
|
361
|
+
)}
|
|
362
|
+
>
|
|
363
|
+
<div className="flex items-start justify-between gap-4">
|
|
364
|
+
<div className="flex flex-col gap-1">
|
|
365
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
366
|
+
<span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>
|
|
367
|
+
<span className="rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground">
|
|
368
|
+
{formatEntityId(result.entityId)}
|
|
369
|
+
</span>
|
|
370
|
+
{!hasLink && (
|
|
371
|
+
<span className="rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400">
|
|
372
|
+
{t('search.dialog.noLink')}
|
|
373
|
+
</span>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
{presenter?.subtitle ? (
|
|
377
|
+
<div className="text-sm text-muted-foreground whitespace-normal break-words">{presenter.subtitle}</div>
|
|
378
|
+
) : null}
|
|
379
|
+
{normalizeLinks(result.links).length ? (
|
|
380
|
+
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
381
|
+
{normalizeLinks(result.links).map((link) => (
|
|
382
|
+
<span
|
|
383
|
+
key={`${link.href}`}
|
|
384
|
+
className={cn(
|
|
385
|
+
'rounded-full border px-2 py-0.5 text-xs',
|
|
386
|
+
link.kind === 'primary'
|
|
387
|
+
? 'border-primary text-primary'
|
|
388
|
+
: 'border-muted-foreground/40 text-muted-foreground'
|
|
389
|
+
)}
|
|
390
|
+
>
|
|
391
|
+
{link.label ?? link.href}
|
|
392
|
+
</span>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
396
|
+
</div>
|
|
397
|
+
{Icon ? (
|
|
398
|
+
<div className="flex flex-col items-end gap-2">
|
|
399
|
+
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
400
|
+
</div>
|
|
401
|
+
) : null}
|
|
402
|
+
</div>
|
|
403
|
+
</button>
|
|
404
|
+
</li>
|
|
405
|
+
)
|
|
406
|
+
})}
|
|
407
|
+
</ul>
|
|
408
|
+
</div>
|
|
409
|
+
<div className="flex items-center justify-between border-t px-4 py-3">
|
|
410
|
+
<span className="text-xs text-muted-foreground">
|
|
411
|
+
{selectedResult && !selectedHasLink
|
|
412
|
+
? t('search.dialog.noLinkHint')
|
|
413
|
+
: t('search.dialog.shortcuts.hint')}
|
|
414
|
+
</span>
|
|
415
|
+
<div className="flex items-center gap-2">
|
|
416
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
|
417
|
+
{t('search.dialog.actions.cancel')}
|
|
418
|
+
</Button>
|
|
419
|
+
<Button
|
|
420
|
+
type="button"
|
|
421
|
+
size="sm"
|
|
422
|
+
onClick={() => openResult(results[selectedIndex])}
|
|
423
|
+
disabled={!results.length || !selectedHasLink}
|
|
424
|
+
>
|
|
425
|
+
{t('search.dialog.actions.openSelected')}
|
|
426
|
+
</Button>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</DialogContent>
|
|
430
|
+
</Dialog>
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export default GlobalSearchDialog
|
|
436
|
+
const TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>
|