Skip to content

Commit 88fbe59

Browse files
committed
feat(knowledge): search results a person can open, from Chat
- The composer's Search mode searches every knowledge base as the signed-in person and lists what they may read as result cards: source icon, title linking back to the document, knowledge base, updated date, and the matching passage with the query terms in bold; Summarize hands a document to the agent in Build mode - The agent's knowledge tool returns each result's title, link, connector, and modified time and is told to cite with source tags carrying a snippet; a reply whose sources carry snippets ends with the same cards - A session route for the search, bound to the shared search use case, so the browser reads through the same access predicate as everything else - Search quality: hybrid legs over-fetch before fusion, the vector leg's iterative scan fills a limit past the default candidate pool, and the recency weight moves a fresh document a few places rather than the list - A manual member sync makes every active member due, so Sync members now lists everyone instead of nobody
1 parent 1628ba9 commit 88fbe59

23 files changed

Lines changed: 592 additions & 37 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { searchWorkspaceKnowledgeContract } from '@/lib/api/contracts/knowledge'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { searchKnowledge } from '@/lib/knowledge/application/search'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: searchWorkspaceKnowledgeContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.search,
15+
rateLimit: internalRateLimits.none({
16+
reason: 'A person typing queries; the embedding call is metered against their workspace',
17+
}),
18+
errorPolicy: internalKnowledgeErrorPolicies.search,
19+
mapInput: ({ body }) => ({
20+
workspaceId: body.workspaceId,
21+
knowledgeBaseIds: body.knowledgeBaseIds,
22+
query: body.query,
23+
topK: body.topK,
24+
}),
25+
useCase: searchKnowledge,
26+
present: ({ results, knowledgeBases }, { input }) => {
27+
const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name]))
28+
return {
29+
success: true as const,
30+
data: {
31+
query: input.query ?? '',
32+
results: results.map((result) => ({
33+
documentId: result.documentId,
34+
knowledgeBaseId: result.knowledgeBaseId,
35+
knowledgeBaseName: knowledgeBaseNames.get(result.knowledgeBaseId) ?? '',
36+
documentName: result.documentName,
37+
sourceUrl: result.sourceUrl,
38+
connectorType: result.connectorType,
39+
sourceModifiedAt: result.sourceModifiedAt?.toISOString() ?? null,
40+
content: result.content,
41+
chunkIndex: result.chunkIndex,
42+
similarity: result.similarity,
43+
})),
44+
},
45+
}
46+
},
47+
})

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -510,12 +510,13 @@ describe('Knowledge Search Utils', () => {
510510

511511
it('runs both legs and fuses them in hybrid mode', async () => {
512512
/**
513-
* Chains dequeue in creation order. A workspace-scoped vector leg selects
514-
* directly (the iterative scan is reserved for a personal token set), so
515-
* its select is built first, then the keyword ranking pass, then hydration.
513+
* Chains dequeue in creation order. Hybrid legs over-fetch past the
514+
* plain scan's candidate pool, so the vector leg opens its transaction
515+
* and applies the scan settings before selecting: the keyword ranking
516+
* pass is built first, then the vector select, then hydration.
516517
*/
517-
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
518518
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
519+
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
519520
queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')])
520521

521522
const results = await executeKnowledgeSearch({
@@ -532,9 +533,9 @@ describe('Knowledge Search Utils', () => {
532533
})
533534

534535
it('falls back to vector results when the keyword leg fails', async () => {
535-
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
536-
/** The failing ranking chain is still built and takes the second queued set. */
536+
/** The failing ranking chain is still built first and takes the first queued set. */
537537
queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }])
538+
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
538539

539540
/**
540541
* Both legs share one `orderBy` spy, so target the keyword leg by its
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { groupResultsByDocument, KnowledgeSearchResults } from './knowledge-search-results'
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
5+
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
6+
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
7+
import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
8+
9+
/** A search spans at most this many knowledge bases. */
10+
const MAX_SEARCHED_KNOWLEDGE_BASES = 20
11+
/** Characters of the matching chunk shown under a result. */
12+
const SNIPPET_LENGTH = 280
13+
14+
function toSnippet(content: string): string {
15+
const flat = content.replace(/\s+/g, ' ').trim()
16+
return flat.length > SNIPPET_LENGTH ? `${flat.slice(0, SNIPPET_LENGTH).trimEnd()}…` : flat
17+
}
18+
19+
/**
20+
* One card per document, keeping the best-ranked chunk of each: the list is
21+
* already in rank order, so the first chunk seen for a document is its best.
22+
*/
23+
export function groupResultsByDocument(
24+
results: readonly WorkspaceKnowledgeSearchResult[]
25+
): WorkspaceKnowledgeSearchResult[] {
26+
const seen = new Set<string>()
27+
const grouped: WorkspaceKnowledgeSearchResult[] = []
28+
for (const result of results) {
29+
if (seen.has(result.documentId)) continue
30+
seen.add(result.documentId)
31+
grouped.push(result)
32+
}
33+
return grouped
34+
}
35+
36+
/** A result as the source card renders it; a document without a source URL cannot be opened. */
37+
function toSource(result: WorkspaceKnowledgeSearchResult): SourceTagData | null {
38+
if (!result.sourceUrl) return null
39+
return {
40+
url: result.sourceUrl,
41+
title: result.documentName ?? undefined,
42+
siteName: result.knowledgeBaseName || undefined,
43+
connectorType: result.connectorType ?? undefined,
44+
snippet: toSnippet(result.content),
45+
updatedAt: result.sourceModifiedAt ?? undefined,
46+
}
47+
}
48+
49+
interface KnowledgeSearchResultsProps {
50+
workspaceId: string
51+
query: string
52+
/** Asks the agent about one document; the prompt names it and links to it. */
53+
onSummarize: (prompt: string) => void
54+
}
55+
56+
/**
57+
* The composer's Search mode: the documents the signed-in person may read that
58+
* match their query, across every knowledge base in the workspace, as cards
59+
* that open the source. Summarize hands one document to the agent.
60+
*/
61+
export function KnowledgeSearchResults({
62+
workspaceId,
63+
query,
64+
onSummarize,
65+
}: KnowledgeSearchResultsProps) {
66+
const { data: knowledgeBases = [], isPending: basesPending } = useKnowledgeBasesQuery(workspaceId)
67+
const knowledgeBaseIds = useMemo(
68+
() => knowledgeBases.slice(0, MAX_SEARCHED_KNOWLEDGE_BASES).map((kb) => kb.id),
69+
[knowledgeBases]
70+
)
71+
const {
72+
data: results,
73+
isPending,
74+
isFetching,
75+
error,
76+
} = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query)
77+
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
78+
79+
if (!basesPending && knowledgeBaseIds.length === 0) {
80+
return (
81+
<p className='px-2 py-3 text-[var(--text-muted)] text-small'>
82+
No knowledge bases to search yet. Add one from the Knowledge tab.
83+
</p>
84+
)
85+
}
86+
if (error) {
87+
return <p className='px-2 py-3 text-[var(--text-error)] text-small'>{error.message}</p>
88+
}
89+
if (isPending || (isFetching && !results)) {
90+
return <p className='px-2 py-3 text-[var(--text-muted)] text-small'>Searching…</p>
91+
}
92+
if (documents.length === 0) {
93+
return (
94+
<p className='px-2 py-3 text-[var(--text-muted)] text-small'>
95+
No documents you can read match “{query}”.
96+
</p>
97+
)
98+
}
99+
100+
return (
101+
<div className='flex flex-col gap-0.5'>
102+
{documents.map((result) => {
103+
const source = toSource(result)
104+
return source ? (
105+
<SourceCard
106+
key={result.documentId}
107+
source={source}
108+
query={query}
109+
onSummarize={(cited) =>
110+
onSummarize(`Summarize "${cited.title ?? cited.url}" (${cited.url})`)
111+
}
112+
/>
113+
) : (
114+
<div key={result.documentId} className='flex flex-col gap-0.5 px-2 py-2'>
115+
<p className='truncate text-[var(--text-primary)] text-sm'>
116+
{result.documentName ?? 'Untitled document'}
117+
</p>
118+
<p className='truncate text-[var(--text-muted)] text-caption'>
119+
{result.knowledgeBaseName}
120+
</p>
121+
<p className='line-clamp-2 text-[var(--text-body)] text-small leading-snug'>
122+
{toSnippet(result.content)}
123+
</p>
124+
</div>
125+
)
126+
})}
127+
</div>
128+
)
129+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { ChatContent } from './chat-content'
44
export { MessageSources } from './message-sources'
55
export { Options } from './options'
66
export { QuestionDisplay } from './question'
7+
export { highlightTerms, SourceCard } from './source-card'
78
export { SourceChip, sourceLabel } from './source-chip'
89
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { cn } from '@sim/emcn'
4+
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
45
import { SourceChip } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
56
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
67

@@ -14,17 +15,41 @@ const STRIP_FADE_CLASSES =
1415

1516
interface MessageSourcesProps {
1617
sources: readonly SourceTagData[]
18+
/** The question the reply answers; its terms are bolded in result cards. */
19+
query?: string
20+
/** Asks the agent about one cited document, when the surface can send a message. */
21+
onSummarize?: (prompt: string) => void
1722
}
1823

1924
/**
20-
* Footer strip listing every document a reply cited, once each: one
21-
* horizontally scrolling row of {@link SourceChip}s that fades out at the right
22-
* edge instead of wrapping, so a long list stays a single quiet line under the
23-
* answer.
25+
* Footer listing every document a reply cited, once each. Sources that carry
26+
* a snippet — a search answer — are laid out as result cards; otherwise one
27+
* horizontally scrolling row of {@link SourceChip}s that fades out at the
28+
* right edge instead of wrapping, so a long list stays a single quiet line
29+
* under the answer.
2430
*/
25-
export function MessageSources({ sources }: MessageSourcesProps) {
31+
export function MessageSources({ sources, query, onSummarize }: MessageSourcesProps) {
2632
if (sources.length === 0) return null
2733

34+
if (sources.some((source) => source.snippet)) {
35+
return (
36+
<div className='flex flex-col gap-0.5'>
37+
{sources.map((source) => (
38+
<SourceCard
39+
key={source.url}
40+
source={source}
41+
query={query}
42+
onSummarize={
43+
onSummarize
44+
? (cited) => onSummarize(`Summarize "${cited.title ?? cited.url}" (${cited.url})`)
45+
: undefined
46+
}
47+
/>
48+
))}
49+
</div>
50+
)
51+
}
52+
2853
return (
2954
<div
3055
className={cn(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { highlightTerms, SourceCard } from './source-card'

0 commit comments

Comments
 (0)