Skip to content

Commit 12c671d

Browse files
committed
feat(sim-search): search Slack live as the person asking, instead of crawling it
1 parent 48d6ef5 commit 12c671d

17 files changed

Lines changed: 913 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { searchSimSearchSlackContract } 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 { searchSimSearchSlack } from '@/lib/knowledge/application/sim-search'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: searchSimSearchSlackContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.simSearchFederated,
15+
rateLimit: internalRateLimits.none({
16+
reason:
17+
"Slack's own per-user limit bounds this, and a failed call degrades to no Slack results",
18+
}),
19+
errorPolicy: internalKnowledgeErrorPolicies.search,
20+
mapInput: ({ body }) => ({
21+
workspaceId: body.workspaceId,
22+
query: body.query,
23+
limit: body.limit,
24+
}),
25+
useCase: searchSimSearchSlack,
26+
present: (result) => ({ success: true as const, data: result }),
27+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowled
99
import { matchSnippet } from '@/lib/knowledge/search/snippet'
1010
import { connectorDisplayName } from '@/lib/sim-search/connectors'
1111
import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases'
12+
import { SlackSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results'
1213
import {
1314
highlightTerms,
1415
SOURCE_ROW_CLASSES,
@@ -316,6 +317,7 @@ export function KnowledgeSearchResults({
316317
})}
317318
</div>
318319
)}
320+
<SlackSearchResults workspaceId={workspaceId} query={query} onSummarize={onSummarize} />
319321
</div>
320322
)
321323
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client'
2+
3+
import type { SimSearchSlackResult } from '@/lib/api/contracts/knowledge'
4+
import { matchSnippet } from '@/lib/knowledge/search/snippet'
5+
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
6+
import {
7+
isHttpUrl,
8+
type SourceTagData,
9+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
10+
import { useSimSearchSlack } from '@/hooks/queries/kb/knowledge'
11+
12+
function toSource(result: SimSearchSlackResult, query: string): SourceTagData | null {
13+
if (!isHttpUrl(result.permalink)) return null
14+
return {
15+
url: result.permalink,
16+
title: `#${result.channelName}`,
17+
siteName: 'Slack',
18+
connectorType: 'slack',
19+
snippet: matchSnippet(result.text, query),
20+
author: result.authorName,
21+
updatedAt: result.sentAt ?? undefined,
22+
}
23+
}
24+
25+
interface SlackSearchResultsProps {
26+
workspaceId: string
27+
query: string
28+
/** Asks the agent about one message; the prompt names it and links to it. */
29+
onSummarize: (prompt: string) => void
30+
}
31+
32+
/**
33+
* Slack messages matching the query, searched live as the signed-in person.
34+
*
35+
* Its own group rather than blended into the indexed results: Slack ranks with
36+
* its own relevance, which is not comparable to a vector distance, so
37+
* interleaving the two lists would present an ordering that means nothing.
38+
* Nothing renders while Slack is unconnected — the Sources strip is where a
39+
* person connects it — but a connection that stopped working says so, because
40+
* reconnecting is something they can act on.
41+
*/
42+
export function SlackSearchResults({ workspaceId, query, onSummarize }: SlackSearchResultsProps) {
43+
const { data, isPending, isPlaceholderData } = useSimSearchSlack(workspaceId, query)
44+
45+
if (!data || isPending || isPlaceholderData) return null
46+
if (data.status === 'not_connected') return null
47+
48+
if (data.status === 'needs_reauth') {
49+
return (
50+
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
51+
Reconnect Slack from Sources to search it.
52+
</p>
53+
)
54+
}
55+
if (data.status === 'unavailable') {
56+
return (
57+
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
58+
Slack could not be searched this time.
59+
</p>
60+
)
61+
}
62+
if (data.results.length === 0) return null
63+
64+
return (
65+
<div className='flex flex-col'>
66+
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
67+
<span className='tabular-nums'>
68+
{data.results.length === 1 ? '1 Slack message' : `${data.results.length} Slack messages`}
69+
</span>
70+
{' · searched in Slack as you'}
71+
</p>
72+
{data.results.map((result) => {
73+
const source = toSource(result, query)
74+
return source ? (
75+
<SourceCard
76+
key={`${result.channelId}:${result.messageTs}`}
77+
source={source}
78+
query={query}
79+
onSummarize={(cited) =>
80+
onSummarize(`Summarize this Slack message from ${cited.title} (${cited.url})`)
81+
}
82+
/>
83+
) : null
84+
})}
85+
</div>
86+
)
87+
}

‎apps/sim/hooks/queries/kb/knowledge.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ import {
4040
nextAvailableSlotContract,
4141
restoreKnowledgeBaseContract,
4242
type SaveDocumentTagDefinitionsResult,
43+
type SearchSimSearchSlackBody,
4344
saveDocumentTagDefinitionsContract,
45+
searchSimSearchSlackContract,
4446
searchWorkspaceKnowledgeContract,
4547
type TagDefinitionData,
4648
type TagUsageData,
@@ -78,6 +80,8 @@ export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000
7880
export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000
7981
export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000
8082
export const WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME = 60 * 1000
83+
/** Slack answers live, so a result is only reused for as long as a person keeps typing one query. */
84+
export const SIM_SEARCH_SLACK_STALE_TIME = 30 * 1000
8185
export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
8286
export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000
8387
export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
@@ -1192,3 +1196,28 @@ export function useWorkspaceKnowledgeSearch(
11921196
placeholderData: keepPreviousData,
11931197
})
11941198
}
1199+
1200+
async function searchSlack(body: SearchSimSearchSlackBody, signal?: AbortSignal) {
1201+
const data = await requestJson(searchSimSearchSlackContract, { body, signal })
1202+
return data.data
1203+
}
1204+
1205+
/**
1206+
* What Slack returns for `query`, searched live as the signed-in person.
1207+
*
1208+
* Slack is not indexed, so this asks Slack itself on every new query. The
1209+
* answer is short-lived on purpose: a conversation that moved on since the
1210+
* last search should not be served from a cache, and Slack's own per-person
1211+
* rate limit is what keeps the call rate sane.
1212+
*/
1213+
export function useSimSearchSlack(workspaceId: string | undefined, query: string) {
1214+
const trimmed = query.trim()
1215+
return useQuery({
1216+
queryKey: knowledgeKeys.slackSearch(workspaceId, trimmed),
1217+
queryFn: ({ signal }) =>
1218+
searchSlack({ workspaceId: workspaceId as string, query: trimmed }, signal),
1219+
enabled: Boolean(workspaceId) && trimmed.length > 0,
1220+
staleTime: SIM_SEARCH_SLACK_STALE_TIME,
1221+
placeholderData: keepPreviousData,
1222+
})
1223+
}

‎apps/sim/hooks/queries/utils/knowledge-keys.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export const knowledgeKeys = {
3131
detail: (knowledgeBaseId?: string) =>
3232
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
3333
searches: () => [...knowledgeKeys.all, 'search'] as const,
34+
slackSearches: () => [...knowledgeKeys.all, 'slackSearch'] as const,
35+
slackSearch: (workspaceId: string | undefined, query: string) =>
36+
[...knowledgeKeys.slackSearches(), workspaceId ?? '', query] as const,
3437
search: (workspaceId: string | undefined, knowledgeBaseIds: readonly string[], query: string) =>
3538
[
3639
...knowledgeKeys.searches(),

‎apps/sim/lib/api/contracts/knowledge/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from '@/lib/api/contracts/knowledge/chunks'
33
export * from '@/lib/api/contracts/knowledge/connectors'
44
export * from '@/lib/api/contracts/knowledge/documents'
55
export * from '@/lib/api/contracts/knowledge/search'
6+
export * from '@/lib/api/contracts/knowledge/sim-search'
67
export * from '@/lib/api/contracts/knowledge/tags'
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { z } from 'zod'
2+
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
3+
import { defineRouteContract } from '@/lib/api/contracts/types'
4+
import { SLACK_SEARCH_MAX_LIMIT } from '@/lib/slack-search/client'
5+
6+
export const MAX_SIM_SEARCH_SLACK_QUERY_LENGTH = 2000
7+
8+
const slackSearchBodySchema = z.object({
9+
workspaceId: workspaceIdSchema,
10+
query: z
11+
.string()
12+
.min(1, 'query cannot be empty')
13+
.max(
14+
MAX_SIM_SEARCH_SLACK_QUERY_LENGTH,
15+
`query cannot exceed ${MAX_SIM_SEARCH_SLACK_QUERY_LENGTH} characters`
16+
),
17+
limit: z.number().int().min(1).max(SLACK_SEARCH_MAX_LIMIT).optional(),
18+
})
19+
20+
const slackSearchResultSchema = z.object({
21+
channelId: z.string(),
22+
messageTs: z.string(),
23+
channelName: z.string(),
24+
authorName: z.string(),
25+
text: z.string(),
26+
permalink: z.string().url(),
27+
/** ISO-8601, or null when Slack returned a timestamp that would not parse. */
28+
sentAt: z.string().nullable(),
29+
})
30+
31+
/**
32+
* Whether Slack answered, and if not, what the person can do about it.
33+
* `needs_reauth` is its own state because reconnecting is a real action they
34+
* can take, where `unavailable` is nothing they can fix.
35+
*/
36+
const slackSearchStatusSchema = z.enum(['ok', 'not_connected', 'needs_reauth', 'unavailable'])
37+
38+
/**
39+
* Searching Slack for the person asking, live, under their own Slack account.
40+
* Nothing is indexed and no result is stored, so this is a read that returns
41+
* exactly what that person could have found in Slack themselves.
42+
*/
43+
export const searchSimSearchSlackContract = defineRouteContract({
44+
method: 'POST',
45+
path: '/api/knowledge/sim-search/slack',
46+
body: slackSearchBodySchema,
47+
response: {
48+
mode: 'json',
49+
schema: z.object({
50+
success: z.literal(true),
51+
data: z.object({
52+
status: slackSearchStatusSchema,
53+
results: z.array(slackSearchResultSchema),
54+
}),
55+
}),
56+
},
57+
})
58+
59+
export type SearchSimSearchSlackBody = z.input<typeof slackSearchBodySchema>
60+
export type SimSearchSlackResult = z.output<typeof slackSearchResultSchema>
61+
export type SimSearchSlackStatus = z.output<typeof slackSearchStatusSchema>

‎apps/sim/lib/credential-groups/slack-managed-user-scopes.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ export const SLACK_MANAGED_USER_SCOPES = [
2222
'mpim:write',
2323
'reactions:read',
2424
'reactions:write',
25+
/**
26+
* Federated Slack search (`assistant.search.context`). Slack requires at
27+
* least `search:read.public`; each other scope widens what the search covers,
28+
* and the four together let a person's own search reach exactly the
29+
* conversations they can already read in Slack. `search:read.files` and
30+
* `search:read.users` are deliberately absent: this searches messages, and an
31+
* unused scope is one more thing every member is asked to grant.
32+
*
33+
* https://docs.slack.dev/ai/using-data-access-api
34+
*/
35+
'search:read.public',
36+
'search:read.private',
37+
'search:read.im',
38+
'search:read.mpim',
2539
'users.profile:read',
2640
'users.profile:write',
2741
'users:read',

‎apps/sim/lib/knowledge/application/operations.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe('knowledge operation registry', () => {
5959
'knowledge.connectors.members.list',
6060
'knowledge.connectors.members.enroll',
6161
'knowledge.simSearch.connect',
62+
'knowledge.simSearch.federated',
6263
'knowledge.connectors.delete',
6364
'knowledge.connectors.sync',
6465
'knowledge.connectors.documents.list',

‎apps/sim/lib/knowledge/application/operations.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,19 @@ export const knowledgeOperations = {
430430
capability: 'knowledge.use',
431431
principalKinds: ['session'],
432432
}),
433+
/**
434+
* Searching a federated Sim Search source, which runs under the asking
435+
* person's own connected account. There is no actorless form of it — without
436+
* a person there is no account to search as — so it is session-only, exactly
437+
* like connecting one.
438+
*/
439+
simSearchFederated: defineWorkspaceOperation({
440+
id: 'knowledge.simSearch.federated',
441+
minimumRole: 'read',
442+
workspaceApiKey: 'deny',
443+
capability: 'knowledge.use',
444+
principalKinds: ['session'],
445+
}),
433446
deleteConnector: defineWorkspaceOperation({
434447
id: 'knowledge.connectors.delete',
435448
minimumRole: 'write',

0 commit comments

Comments
 (0)