Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/sim/app/api/knowledge/sim-search/slack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { searchSimSearchSlackContract } from '@/lib/api/contracts/knowledge'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { searchSimSearchSlack } from '@/lib/knowledge/application/sim-search'

export const POST = defineInternalJsonRoute({
contract: searchSimSearchSlackContract,
auth: internalSessionAuth,
operation: knowledgeOperations.simSearchFederated,
rateLimit: internalRateLimits.none({
reason:
"Slack's own per-user limit bounds this, and a failed call degrades to no Slack results",
}),
errorPolicy: internalKnowledgeErrorPolicies.search,
mapInput: ({ body }) => ({
workspaceId: body.workspaceId,
query: body.query,
limit: body.limit,
}),
Comment thread
waleedlatif1 marked this conversation as resolved.
useCase: searchSimSearchSlack,
present: (result) => ({ success: true as const, data: result }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowled
import { matchSnippet } from '@/lib/knowledge/search/snippet'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases'
import { SlackSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results'
import {
highlightTerms,
SOURCE_ROW_CLASSES,
Expand Down Expand Up @@ -225,28 +226,28 @@ export function KnowledgeSearchResults({
}, [documents, filtersActive, filters.source, filters.updated])

const failure = basesError ?? error
if (failure) {
return <p className='px-2 py-2 text-[var(--text-error)] text-caption'>{failure.message}</p>
}
if (!basesPending && knowledgeBaseIds.length === 0) {
return (
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
Nothing to search yet. Clear the query and connect a source to index what you can open.
</p>
)
}
/** Kept results belong to the previous query; a new query shows its own state. */
if (isPending || isPlaceholderData || (isFetching && !results)) {
return <p className='px-2 py-2 text-[var(--text-muted)] text-caption'>Searching…</p>
}

const indexingNote =
indexing.length > 0
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
: null

return (
<div className='flex flex-col'>
/**
* The indexed half of the page, in whatever state it is in. It is a branch
* rather than an early return because a federated source is searched even
* where there is nothing indexed at all — a workspace whose only source is
* Slack has no knowledge base, and its failures are not Slack's.
*/
const knowledgeSection = failure ? (
<p className='px-2 py-2 text-[var(--text-error)] text-caption'>{failure.message}</p>
) : !basesPending && knowledgeBaseIds.length === 0 ? (
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
No indexed sources yet. Connect one to index what you can open.
</p>
) : /** Kept results belong to the previous query; a new query shows its own state. */
isPending || isPlaceholderData || (isFetching && !results) ? (
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>Searching…</p>
) : (
<>
<div className='flex items-center gap-2 px-2 py-2'>
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
<span className='tabular-nums'>
Expand Down Expand Up @@ -298,7 +299,7 @@ export function KnowledgeSearchResults({
: 'No documents match these filters.'}
</p>
) : (
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
<div className='flex flex-col'>
{visible.map((result) => {
const source = toSource(result, query)
return source ? (
Expand All @@ -316,6 +317,14 @@ export function KnowledgeSearchResults({
})}
</div>
)}
</>
)

/** One keyboard container over both groups, so the arrows walk every result. */
return (
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
{knowledgeSection}
<SlackSearchResults workspaceId={workspaceId} query={query} onSummarize={onSummarize} />
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client'

import type { SimSearchSlackResult } from '@/lib/api/contracts/knowledge'
import { matchSnippet } from '@/lib/knowledge/search/snippet'
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
import {
isHttpUrl,
type SourceTagData,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { useSimSearchSlack } from '@/hooks/queries/kb/knowledge'

function toSource(result: SimSearchSlackResult, query: string): SourceTagData | null {
if (!isHttpUrl(result.permalink)) return null
return {
url: result.permalink,
title: `#${result.channelName}`,
siteName: 'Slack',
connectorType: 'slack',
snippet: matchSnippet(result.text, query),
author: result.authorName,
updatedAt: result.sentAt ?? undefined,
}
}

interface SlackSearchResultsProps {
workspaceId: string
query: string
/** Asks the agent about one message; the prompt names it and links to it. */
onSummarize: (prompt: string) => void
}

/**
* Slack messages matching the query, searched live as the signed-in person.
*
* Its own group rather than blended into the indexed results: Slack ranks with
* its own relevance, which is not comparable to a vector distance, so
* interleaving the two lists would present an ordering that means nothing.
* Nothing renders while Slack is unconnected — the Sources strip is where a
* person connects it — but a connection that stopped working says so, because
* reconnecting is something they can act on.
*/
export function SlackSearchResults({ workspaceId, query, onSummarize }: SlackSearchResultsProps) {
const { data, isPending, isPlaceholderData } = useSimSearchSlack(workspaceId, query)

if (!data || isPending || isPlaceholderData) return null
if (data.status === 'not_connected') return null

if (data.status === 'needs_reauth') {
return (
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
Reconnect Slack from Sources to search it.
</p>
)
}
if (data.status === 'unavailable') {
return (
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
Slack could not be searched this time.
</p>
)
}
if (data.results.length === 0) return null

return (
<div className='flex flex-col'>
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
<span className='tabular-nums'>
{data.results.length === 1 ? '1 Slack message' : `${data.results.length} Slack messages`}
</span>
{' · searched in Slack as you'}
</p>
{data.results.map((result) => {
const source = toSource(result, query)
return source ? (
<SourceCard
key={`${result.channelId}:${result.messageTs}`}
source={source}
query={query}
onSummarize={(cited) =>
onSummarize(`Summarize this Slack message from ${cited.title} (${cited.url})`)
}
/>
) : null
})}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
useWorkspaceMemberConnectors,
type WorkspaceMemberConnector,
} from '@/hooks/queries/kb/connectors'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment'
import { usePermissionConfig } from '@/hooks/use-permission-config'
Expand Down Expand Up @@ -169,7 +170,15 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
),
[memberConnectors]
)
const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
/**
* What a finished connection changes. The federated Slack search is here
* too: it holds its answer briefly, and someone who just reconnected Slack
* should stop being told to reconnect it rather than wait that out.
*/
const membershipQueryKeys = useMemo(
() => [memberConnectorKeys.list(workspaceId), knowledgeKeys.slackSearches()],
[workspaceId]
)
const {
connectSource,
connectSearchSource,
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/hooks/queries/kb/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
nextAvailableSlotContract,
restoreKnowledgeBaseContract,
type SaveDocumentTagDefinitionsResult,
type SearchSimSearchSlackBody,
saveDocumentTagDefinitionsContract,
searchSimSearchSlackContract,
searchWorkspaceKnowledgeContract,
type TagDefinitionData,
type TagUsageData,
Expand All @@ -52,6 +54,7 @@ import {
type WorkspaceKnowledgeSearchBody,
type WorkspaceKnowledgeSearchResult,
} from '@/lib/api/contracts/knowledge'
import { useSession } from '@/lib/auth/auth-client'
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
Expand All @@ -78,6 +81,8 @@ export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000
export const WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME = 60 * 1000
/** Slack answers live, so a result is only reused for as long as a person keeps typing one query. */
export const SIM_SEARCH_SLACK_STALE_TIME = 30 * 1000
export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000
export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
Expand Down Expand Up @@ -1192,3 +1197,31 @@ export function useWorkspaceKnowledgeSearch(
placeholderData: keepPreviousData,
})
}

async function searchSlack(body: SearchSimSearchSlackBody, signal?: AbortSignal) {
const data = await requestJson(searchSimSearchSlackContract, { body, signal })
return data.data
}

/**
* What Slack returns for `query`, searched live as the signed-in person.
*
* Slack is not indexed, so this asks Slack itself on every new query. The
* answer is short-lived on purpose: a conversation that moved on since the
* last search should not be served from a cache, and Slack's own per-person
* rate limit is what keeps the call rate sane.
*/
export function useSimSearchSlack(workspaceId: string | undefined, query: string) {
const trimmed = query.trim()
const { data: session } = useSession()
const viewerId = session?.user?.id
return useQuery({
queryKey: knowledgeKeys.slackSearch(workspaceId, viewerId, trimmed),
queryFn: ({ signal }) =>
searchSlack({ workspaceId: workspaceId as string, query: trimmed }, signal),
/** Held until the viewer is known, so no answer is ever cached under an empty identity. */
enabled: Boolean(workspaceId) && Boolean(viewerId) && trimmed.length > 0,
staleTime: SIM_SEARCH_SLACK_STALE_TIME,
Comment thread
waleedlatif1 marked this conversation as resolved.
placeholderData: keepPreviousData,
})
}
8 changes: 8 additions & 0 deletions apps/sim/hooks/queries/utils/knowledge-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export const knowledgeKeys = {
detail: (knowledgeBaseId?: string) =>
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
searches: () => [...knowledgeKeys.all, 'search'] as const,
slackSearches: () => [...knowledgeKeys.all, 'slackSearch'] as const,
/**
* Keyed by viewer as well as workspace: the answer is one person's own Slack,
* including their direct messages, so a session change in an open tab must
* never be served another person's cached results.
*/
slackSearch: (workspaceId: string | undefined, userId: string | undefined, query: string) =>
[...knowledgeKeys.slackSearches(), workspaceId ?? '', userId ?? '', query] as const,
search: (workspaceId: string | undefined, knowledgeBaseIds: readonly string[], query: string) =>
[
...knowledgeKeys.searches(),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/knowledge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from '@/lib/api/contracts/knowledge/chunks'
export * from '@/lib/api/contracts/knowledge/connectors'
export * from '@/lib/api/contracts/knowledge/documents'
export * from '@/lib/api/contracts/knowledge/search'
export * from '@/lib/api/contracts/knowledge/sim-search'
export * from '@/lib/api/contracts/knowledge/tags'
66 changes: 66 additions & 0 deletions apps/sim/lib/api/contracts/knowledge/sim-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { SLACK_SEARCH_MAX_LIMIT } from '@/lib/slack-search/client'

export const MAX_SIM_SEARCH_SLACK_QUERY_LENGTH = 2000

export const slackSearchBodySchema = z.object({
workspaceId: workspaceIdSchema,
query: z
.string()
.min(1, 'query cannot be empty')
.max(
MAX_SIM_SEARCH_SLACK_QUERY_LENGTH,
`query cannot exceed ${MAX_SIM_SEARCH_SLACK_QUERY_LENGTH} characters`
),
limit: z.number().int().min(1).max(SLACK_SEARCH_MAX_LIMIT).optional(),
})

export const slackSearchResultSchema = z.object({
channelId: z.string(),
messageTs: z.string(),
channelName: z.string(),
authorName: z.string(),
text: z.string(),
permalink: z.string().url(),
/** ISO-8601, or null when Slack returned a timestamp that would not parse. */
sentAt: z.string().nullable(),
})

/**
* Whether Slack answered, and if not, what the person can do about it.
* `needs_reauth` is its own state because reconnecting is a real action they
* can take, where `unavailable` is nothing they can fix.
*/
export const slackSearchStatusSchema = z.enum([
'ok',
'not_connected',
'needs_reauth',
'unavailable',
])

/**
* Searching Slack for the person asking, live, under their own Slack account.
* Nothing is indexed and no result is stored, so this is a read that returns
* exactly what that person could have found in Slack themselves.
*/
export const searchSimSearchSlackContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/sim-search/slack',
body: slackSearchBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z.object({
status: slackSearchStatusSchema,
results: z.array(slackSearchResultSchema),
}),
}),
},
})

export type SearchSimSearchSlackBody = z.input<typeof slackSearchBodySchema>
export type SimSearchSlackResult = z.output<typeof slackSearchResultSchema>
export type SimSearchSlackStatus = z.output<typeof slackSearchStatusSchema>
14 changes: 14 additions & 0 deletions apps/sim/lib/credential-groups/slack-managed-user-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ export const SLACK_MANAGED_USER_SCOPES = [
'mpim:write',
'reactions:read',
'reactions:write',
/**
* Federated Slack search (`assistant.search.context`). Slack requires at
* least `search:read.public`; each other scope widens what the search covers,
* and the four together let a person's own search reach exactly the
* conversations they can already read in Slack. `search:read.files` and
* `search:read.users` are deliberately absent: this searches messages, and an
* unused scope is one more thing every member is asked to grant.
*
* https://docs.slack.dev/ai/using-data-access-api
*/
'search:read.public',
'search:read.private',
'search:read.im',
'search:read.mpim',
'users.profile:read',
'users.profile:write',
'users:read',
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/knowledge/application/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('knowledge operation registry', () => {
'knowledge.connectors.members.list',
'knowledge.connectors.members.enroll',
'knowledge.simSearch.connect',
'knowledge.simSearch.federated',
'knowledge.connectors.delete',
'knowledge.connectors.sync',
'knowledge.connectors.documents.list',
Expand Down
Loading
Loading