diff --git a/package-lock.json b/package-lock.json index 85bc30e0..9773aa1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@sentry/react": "8.54.0", "@stellar/stellar-sdk": "^12.3.0", "@tensorflow/tfjs": "^4.22.0", - "@tensorflow/tfjs-node": "^4.22.0", + "@tensorflow/tfjs-node": "4.22.0", "d3-array": "^3.2.4", "d3-force-3d": "^3.0.6", "d3-scale": "^4.0.2", @@ -83,6 +83,7 @@ }, "optionalDependencies": { "@ledgerhq/hw-transport-webhid": "^6.29.4", + "@ledgerhq/hw-transport-webusb": "^6.29.4" "@ledgerhq/hw-transport-webusb": "^6.29.4", "@stellar/ledger": "^1.0.0", "ioredis": "^5.4.0" @@ -4416,6 +4417,118 @@ "node": ">=14.18" } }, + "node_modules/@sentry-internal/browser-utils/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz", + "integrity": "sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.54.0.tgz", + "integrity": "sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz", + "integrity": "sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/browser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.54.0.tgz", + "integrity": "sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry-internal/feedback": "8.54.0", + "@sentry-internal/replay": "8.54.0", + "@sentry-internal/replay-canvas": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/core": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", + "integrity": "sha512-tOfZ/umqB2AcHPGbIrsFLcvApdTm9ggpi/kQZFkej7kMphjT+SGBiQfYtjyg9jcRW+ilAR4JXC9BGKsdEQ+8Vw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sentry-internal/feedback": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz", @@ -4621,6 +4734,15 @@ "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, + "node_modules/@sentry/react/node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sentry/types": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.19.7.tgz", @@ -4677,9 +4799,6 @@ "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", "license": "Apache-2.0" }, - "node_modules/@stellar/ledger": { - "optional": true - }, "node_modules/@stellar/stellar-base": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", @@ -11322,6 +11441,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -18259,6 +18397,9 @@ "license": "ISC" }, "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", diff --git a/package.json b/package.json index d1d1e1d5..6415b0f1 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "ci:testing": "npm run test:coverage && npm run test:coverage:check && npm run ci:workflows", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "docs:validate-drift": "node scripts/validate-docs-drift.mjs", "docs:api:generate": "node scripts/generate-api-docs.mjs", "docs:validate-drift": "node scripts/validate-docs-drift.mjs", "api:start": "node api/server.js" @@ -55,6 +56,7 @@ "@sentry/react": "8.54.0", "@stellar/stellar-sdk": "^12.3.0", "@tensorflow/tfjs": "^4.22.0", + "@tensorflow/tfjs-node": "4.22.0", "@tensorflow/tfjs-node": "^4.22.0", "d3-array": "^3.2.4", "d3-force-3d": "^3.0.6", @@ -84,6 +86,7 @@ }, "optionalDependencies": { "@ledgerhq/hw-transport-webhid": "^6.29.4", + "@ledgerhq/hw-transport-webusb": "^6.29.4" "@ledgerhq/hw-transport-webusb": "^6.29.4", "@stellar/ledger": "^1.0.0", "ioredis": "^5.4.0" diff --git a/scripts/validate-docs-drift.mjs b/scripts/validate-docs-drift.mjs index a19867d7..c168d8a7 100644 --- a/scripts/validate-docs-drift.mjs +++ b/scripts/validate-docs-drift.mjs @@ -1,3 +1,164 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const docsRoot = path.join(repoRoot, 'docs'); +const rootPackage = path.join(repoRoot, 'package.json'); + +const externalLinkPattern = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)/; +const markdownLinkPattern = /!?\[[^\]]*\]\(\s*([^\s)]+)(?:\s+"[^"]*")?\s*\)/g; +const npmRunPattern = /npm run\s+([\w:-]+)/g; + +async function collectMarkdownFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await collectMarkdownFiles(fullPath)); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) { + files.push(fullPath); + } + } + return files; +} + +function parseMarkdownLinks(content) { + const links = []; + let match; + while ((match = markdownLinkPattern.exec(content)) !== null) { + links.push(match[1]); + } + return links; +} + +function parseNpmRunCommands(content) { + const scripts = []; + let match; + while ((match = npmRunPattern.exec(content)) !== null) { + scripts.push(match[1]); + } + return scripts; +} + +function isExternalLink(href) { + return externalLinkPattern.test(href) || href.startsWith('mailto:') || href.startsWith('tel:'); +} + +function normalizeLinkHref(href) { + return href.replace(/^\.+\//, '').replace(/#.*$/, ''); +} + +async function fileExists(filePath) { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function resolveLink(sourceFile, rawHref) { + const href = rawHref.split('#', 1)[0]; + if (!href || href.startsWith('#')) { + return null; + } + + const candidates = []; + const isAbsoluteDocs = href.startsWith('docs/') || href.startsWith('/docs/') || href.startsWith('./docs/') || href.startsWith('../docs/'); + + const sourceDir = path.dirname(sourceFile); + const referenced = path.normalize(href); + + if (!path.isAbsolute(referenced)) { + candidates.push(path.join(sourceDir, referenced)); + if (isAbsoluteDocs) { + const relativeToRoot = path.join(repoRoot, referenced.replace(/^\/*/, '')); + candidates.push(relativeToRoot); + } + } else { + candidates.push(path.join(repoRoot, referenced)); + } + + for (const candidate of candidates) { + const normalized = path.normalize(candidate); + if (await fileExists(normalized)) return normalized; + const withMd = `${normalized}.md`; + const withMdx = `${normalized}.mdx`; + const indexMd = path.join(normalized, 'index.md'); + const readmeMd = path.join(normalized, 'README.md'); + if (await fileExists(withMd)) return withMd; + if (await fileExists(withMdx)) return withMdx; + if (await fileExists(indexMd)) return indexMd; + if (await fileExists(readmeMd)) return readmeMd; + } + + return null; +} + +async function loadPackageScripts() { + try { + const pkgJson = JSON.parse(await fs.readFile(rootPackage, 'utf8')); + return new Set(Object.keys(pkgJson.scripts || {})); + } catch (error) { + console.error('Unable to read package.json for npm script validation:', error.message); + return new Set(); + } +} + +async function main() { + const files = await collectMarkdownFiles(docsRoot); + const packageScripts = await loadPackageScripts(); + const errors = []; + + for (const file of files) { + const content = await fs.readFile(file, 'utf8'); + const links = parseMarkdownLinks(content); + for (const link of links) { + if (isExternalLink(link) || link.startsWith('#')) continue; + const resolved = await resolveLink(file, link); + if (!resolved) { + errors.push({ type: 'missing-file', file, link, resolved: path.join(path.dirname(file), link) }); + } + } + + const scripts = parseNpmRunCommands(content); + for (const script of scripts) { + if (!packageScripts.has(script)) { + errors.push({ type: 'missing-script', file, script }); + } + } + } + + if (errors.length === 0) { + console.log('=== Documentation Drift Detection ==='); + console.log(`Scanned ${files.length} documentation files.`); + console.log('No missing docs links or npm scripts were found.'); + process.exit(0); + } + + console.log('=== Documentation Drift Detection ==='); + console.log(`Scanned ${files.length} documentation files.`); + console.log(); + for (const error of errors) { + if (error.type === 'missing-file') { + console.log(`ERROR: File not found: "${error.link}" (resolved: ${error.resolved}) — referenced from ${path.relative(repoRoot, error.file)}`); + } else if (error.type === 'missing-script') { + console.log(`ERROR: Script not found: "npm run ${error.script}" — referenced from ${path.relative(repoRoot, error.file)}`); + } + } + console.log(); + console.log(`Errors: ${errors.length}`); + process.exit(1); +} + +main().catch((error) => { + console.error('Unexpected error during docs drift validation:', error); + process.exit(1); +}); #!/usr/bin/env node import fs from "fs"; import path from "path"; diff --git a/src/components/dashboard/AdvancedSearch.tsx b/src/components/dashboard/AdvancedSearch.tsx index 98621850..f87f681d 100644 --- a/src/components/dashboard/AdvancedSearch.tsx +++ b/src/components/dashboard/AdvancedSearch.tsx @@ -30,6 +30,7 @@ import { import advancedSearchService from '../../lib/advancedSearch.js'; import auditTrail from '../../lib/auditTrail.js'; import { format } from 'date-fns'; +import ConversationalSearch from '../search/ConversationalSearch'; import SemanticSearchPanel from '../search/SemanticSearchPanel'; export default function AdvancedSearch() { @@ -50,6 +51,8 @@ export default function AdvancedSearch() { const [alertCron, setAlertCron] = useState('0 * * * *'); const [shareToken, setShareToken] = useState(''); const [semanticMode, setSemanticMode] = useState(false); + const [conversationalSearchText, setConversationalSearchText] = useState(''); + const [intentSummary, setIntentSummary] = useState(null); const [filters, setFilters] = useState({ dateRange: { start: '', end: '' }, @@ -167,70 +170,109 @@ export default function AdvancedSearch() { setSearchAnalytics(advancedSearchService.getSearchAnalytics()); }; - const handleSearch = async () => { - if (!searchQuery.trim() && !hasActiveFilters()) { + const handleSearch = async ( + overrideQuery?: string, + overrideFilters?: any, + overrideTypes?: string[] + ) => { + const queryText = (overrideQuery ?? searchQuery).trim(); + const activeFilters = overrideFilters ?? filters; + + if (!queryText && !hasActiveFilters(activeFilters)) { return; } setLoading(true); try { const query = { - text: searchQuery.trim(), - types: selectedTypes, - filters: buildFilters(), + text: queryText, + types: overrideTypes ?? selectedTypes, + filters: buildFilters(activeFilters), sort, page: pagination.page, - limit: pagination.limit + limit: pagination.limit, }; const results = advancedSearchService.search(query); setSearchResults(results); auditTrail.logUserAction('Performed advanced search', { - query: searchQuery.trim(), - types: selectedTypes, + query: queryText, + types: overrideTypes ?? selectedTypes, resultCount: results.total, - searchTime: results.searchTime + searchTime: results.searchTime, }); loadData(); } catch (error) { - auditTrail.logError(error, { operation: 'advancedSearch', query: searchQuery }); + auditTrail.logError(error, { operation: 'advancedSearch', query: queryText }); console.error('Search failed:', error); } finally { setLoading(false); } }; - const buildFilters = () => { - const builtFilters = {}; - - if (filters.dateRange.start) { - builtFilters.dateRange = { ...builtFilters.dateRange, start: new Date(filters.dateRange.start).getTime() }; + const handleConversationalQuery = async (query: string, parsed: { filters: any; searchTerms: string[]; intent: any }) => { + setConversationalSearchText(query); + setIntentSummary(`Detected ${parsed.intent.type} intent`); + setSearchQuery(query); + + const newFilters = { + ...filters, + assetType: parsed.filters.assets?.[0] ?? filters.assetType, + addressFilter: parsed.filters.addresses?.[0] ?? filters.addressFilter, + amountRange: { + min: parsed.filters.amounts?.min ?? filters.amountRange.min, + max: parsed.filters.amounts?.max ?? filters.amountRange.max, + }, + memoFilter: filters.memoFilter, + statusFilter: filters.statusFilter, + networkFilter: filters.networkFilter, + dateRange: { + start: parsed.filters.dateRange?.start ? parsed.filters.dateRange.start.toISOString().slice(0, 10) : filters.dateRange.start, + end: parsed.filters.dateRange?.end ? parsed.filters.dateRange.end.toISOString().slice(0, 10) : filters.dateRange.end, + }, + }; + + setFilters(newFilters); + await handleSearch(query, newFilters, selectedTypes); + }; + + const buildFilters = (sourceFilters = filters) => { + const builtFilters: any = {}; + + if (sourceFilters.dateRange?.start) { + const start = typeof sourceFilters.dateRange.start === 'string' + ? new Date(sourceFilters.dateRange.start) + : sourceFilters.dateRange.start; + builtFilters.dateRange = { ...builtFilters.dateRange, start: start.getTime() }; } - if (filters.dateRange.end) { - builtFilters.dateRange = { ...builtFilters.dateRange, end: new Date(filters.dateRange.end).getTime() }; + if (sourceFilters.dateRange?.end) { + const end = typeof sourceFilters.dateRange.end === 'string' + ? new Date(sourceFilters.dateRange.end) + : sourceFilters.dateRange.end; + builtFilters.dateRange = { ...builtFilters.dateRange, end: end.getTime() }; } - - if (filters.assetType) builtFilters.assetType = filters.assetType; - if (filters.operationType) builtFilters.operationType = filters.operationType; - if (filters.addressFilter) builtFilters.addressFilter = filters.addressFilter; - if (filters.memoFilter) builtFilters.memoFilter = filters.memoFilter; - if (filters.statusFilter) builtFilters.statusFilter = filters.statusFilter; - if (filters.networkFilter) builtFilters.networkFilter = filters.networkFilter; - - if (filters.amountRange.min) { - builtFilters.amountRange = { ...builtFilters.amountRange, min: parseFloat(filters.amountRange.min) }; + + if (sourceFilters.assetType) builtFilters.assetType = sourceFilters.assetType; + if (sourceFilters.operationType) builtFilters.operationType = sourceFilters.operationType; + if (sourceFilters.addressFilter) builtFilters.addressFilter = sourceFilters.addressFilter; + if (sourceFilters.memoFilter) builtFilters.memoFilter = sourceFilters.memoFilter; + if (sourceFilters.statusFilter) builtFilters.statusFilter = sourceFilters.statusFilter; + if (sourceFilters.networkFilter) builtFilters.networkFilter = sourceFilters.networkFilter; + + if (sourceFilters.amountRange?.min) { + builtFilters.amountRange = { ...builtFilters.amountRange, min: parseFloat(sourceFilters.amountRange.min) }; } - if (filters.amountRange.max) { - builtFilters.amountRange = { ...builtFilters.amountRange, max: parseFloat(filters.amountRange.max) }; + if (sourceFilters.amountRange?.max) { + builtFilters.amountRange = { ...builtFilters.amountRange, max: parseFloat(sourceFilters.amountRange.max) }; } - + return builtFilters; }; - const hasActiveFilters = () => { - return Object.values(filters).some(value => { + const hasActiveFilters = (sourceFilters = filters) => { + return Object.values(sourceFilters).some(value => { if (typeof value === 'object' && value !== null) { return Object.values(value).some(v => v !== ''); } @@ -344,6 +386,18 @@ export default function AdvancedSearch() { +
+ + {intentSummary && ( +
+ {intentSummary} +
+ )} +
+ {/* Semantic mode toggle */}
)} + {/* end keyword search bar */} {/* Search Results */} diff --git a/src/components/search/ConversationalSearch.tsx b/src/components/search/ConversationalSearch.tsx new file mode 100644 index 00000000..a897c803 --- /dev/null +++ b/src/components/search/ConversationalSearch.tsx @@ -0,0 +1,200 @@ +import React, { useMemo, useState } from 'react'; +import { Search, MessageCircle, ArrowRight, Sparkles } from 'lucide-react'; +import { parseNaturalLanguageQuery, classifyIntent } from '../../lib/nlpSearchEngine'; +import { format } from 'date-fns'; + +const followUpTemplates = { + transaction: [ + 'Do you want to narrow this to a specific account or transaction hash?', + 'Would you like to filter by payment amount or operation type?', + ], + account: [ + 'Should I show only payments, offers, or account changes?', + 'Do you want to see activity from the last 30 days only?', + ], + contract: [ + 'Should I show only invocations or all contract interactions?', + 'Would you like to filter by a specific contract ID or asset?', + ], + general: [ + 'Try asking something like "payments over 1000 XLM last month".', + 'You can refine the query by adding a time range or account ID.', + ], +}; + +interface ConversationalSearchProps { + onQuerySubmit: (query: string, parsed: { filters: any; searchTerms: string[]; intent: any }) => void; + placeholder?: string; +} + +export default function ConversationalSearch({ onQuerySubmit, placeholder }: ConversationalSearchProps) { + const [query, setQuery] = useState(''); + const [history, setHistory] = useState>([]); + const [showFeedback, setShowFeedback] = useState(false); + + const intent = useMemo(() => { + if (!query.trim()) return null; + return classifyIntent(query); + }, [query]); + + const suggestions = useMemo(() => { + if (!intent) return []; + const intentSuggestions = followUpTemplates[intent.type] || followUpTemplates.general; + return intentSuggestions.slice(0, 2); + }, [intent]); + + const [parsedIntent, setParsedIntent] = useState<{ filters: any; searchTerms: string[]; intent: any } | null>(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + + const parsed = parseNaturalLanguageQuery(trimmed); + setParsedIntent(parsed); + onQuerySubmit(trimmed, parsed); + + const nextHistory = [ + { query: trimmed, timestamp: format(new Date(), 'PPpp') }, + ...history.filter((item) => item.query !== trimmed), + ].slice(0, 10); + setHistory(nextHistory); + setQuery(''); + setShowFeedback(true); + }; + + return ( +
+
+ +
+ + setQuery(e.target.value)} + placeholder={placeholder || 'Ask something like "Show me payments over 1000 XLM last month"'} + style={{ + width: '100%', + padding: '12px 140px 12px 40px', + borderRadius: '12px', + border: '1px solid var(--border)', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + fontSize: '14px', + }} + /> + +
+ + {intent && ( +
+
+ + Detected intent +
+
+
Type: {intent.type}
+ {intent.entities.addresses?.length > 0 &&
Addresses: {intent.entities.addresses.join(', ')}
} + {intent.entities.amounts?.length > 0 &&
Amounts: {intent.entities.amounts.join(', ')}
} + {intent.entities.assets?.length > 0 &&
Assets: {intent.entities.assets.join(', ')}
} + {intent.entities.dateRanges?.length > 0 &&
Date: {intent.entities.dateRanges.map((dr) => dr.start ? format(dr.start, 'PPP') : 'recent').join(', ')}
} +
+
+ )} + + {intent && suggestions.length > 0 && ( +
+
Refine your query:
+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+ )} +
+ + {showFeedback && ( +
+
+ + Conversation mode +
+
+ This interface converts plain English into structured Stellar query filters. You can refine the results using follow-up questions or export the final results once they are loaded. +
+
+ )} + + {parsedIntent && ( +
+
Parsed query
+
+
Intent: {parsedIntent.intent.type}
+
Terms: {parsedIntent.searchTerms.join(', ') || 'None'}
+
Filters: {Object.entries(parsedIntent.filters) + .filter(([, value]) => { + if (Array.isArray(value)) return value.length > 0; + return value !== undefined && value !== null && value !== ''; + }) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join('; ') || 'None'} +
+
+
+ )} + + {history.length > 0 && ( +
+
Recent conversational queries
+
    + {history.map((item, idx) => ( +
  • + {item.query} + {item.timestamp} +
  • + ))} +
+
+ )} +
+ ); +}