Skip to content

Commit 5b1500b

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(knowledge): add canonical tag ID input
1 parent 5c37778 commit 5b1500b

33 files changed

Lines changed: 1676 additions & 113 deletions

File tree

apps/docs/content/docs/en/integrations/knowledge.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Search for similar content in a knowledge base using vector similarity
4242
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to search in |
4343
| `query` | string | No | Search query text \(optional when using tag filters\) |
4444
| `topK` | number | No | Number of most similar results to return \(1-100\) |
45-
| `tagFilters` | array | No | Array of tag filters with tagName and tagValue properties |
45+
| `tagFilters` | array | No | Array of tag filters using either tagName or tagId together with tagValue properties |
4646
| `searchMode` | string | No | Retrieval mode: 'vector' \(default\) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both |
4747
| `rerankerEnabled` | boolean | No | Whether to apply Cohere reranking to vector search results |
4848
| `rerankerModel` | string | No | Cohere rerank model to use \(one of: rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5\) |
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCopy, mockRefetchTagUsage } = vi.hoisted(() => ({
9+
mockCopy: vi.fn(),
10+
mockRefetchTagUsage: vi.fn(),
11+
}))
12+
13+
vi.mock('@sim/emcn', async () => {
14+
const { useState } = await import('react')
15+
16+
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>
17+
18+
return {
19+
Button: ({
20+
children,
21+
variant: _variant,
22+
...props
23+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => (
24+
<button {...props}>{children}</button>
25+
),
26+
ChipCombobox: () => null,
27+
ChipConfirmModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
28+
open ? <div>{children}</div> : null,
29+
ChipInput: () => null,
30+
ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
31+
open ? <div>{children}</div> : null,
32+
ChipModalBody: Container,
33+
ChipModalField: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
34+
<div>
35+
{title}
36+
{children}
37+
</div>
38+
),
39+
ChipModalFooter: Container,
40+
ChipModalHeader: Container,
41+
handleKeyboardActivation: vi.fn(),
42+
Tooltip: {
43+
Root: Container,
44+
Trigger: Container,
45+
Content: Container,
46+
},
47+
useCopyToClipboard: () => {
48+
const [copied, setCopied] = useState(false)
49+
return {
50+
copied,
51+
copy: async (text: string) => {
52+
await mockCopy(text)
53+
setCopied(true)
54+
return true
55+
},
56+
}
57+
},
58+
}
59+
})
60+
61+
vi.mock('@sim/emcn/icons', () => ({
62+
Check: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='check' {...props} />,
63+
Duplicate: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='duplicate' {...props} />,
64+
Trash: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='trash' {...props} />,
65+
}))
66+
67+
vi.mock('@/app/workspace/[workspaceId]/knowledge/components', () => ({
68+
getDocumentIcon: () => (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
69+
}))
70+
71+
vi.mock('@/hooks/kb/use-knowledge-base-tag-definitions', () => ({
72+
useKnowledgeBaseTagDefinitions: () => ({
73+
tagDefinitions: [
74+
{
75+
id: 'tag-definition-uuid',
76+
tagSlot: 'tag1',
77+
displayName: 'category',
78+
fieldType: 'text',
79+
},
80+
],
81+
}),
82+
}))
83+
84+
vi.mock('@/hooks/queries/kb/knowledge', () => ({
85+
useCreateTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }),
86+
useDeleteTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }),
87+
useTagUsageQuery: () => ({
88+
data: [
89+
{
90+
tagName: 'category',
91+
tagSlot: 'tag1',
92+
documentCount: 0,
93+
documents: [],
94+
},
95+
],
96+
refetch: mockRefetchTagUsage,
97+
}),
98+
}))
99+
100+
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal'
101+
102+
let container: HTMLDivElement
103+
let root: Root
104+
105+
describe('BaseTagsModal tag ID copy control', () => {
106+
beforeEach(() => {
107+
container = document.createElement('div')
108+
document.body.appendChild(container)
109+
root = createRoot(container)
110+
mockCopy.mockResolvedValue(undefined)
111+
})
112+
113+
afterEach(() => {
114+
act(() => root.unmount())
115+
container.remove()
116+
vi.clearAllMocks()
117+
})
118+
119+
it('copies the tag UUID, shows feedback, and does not open tag usage', async () => {
120+
await act(async () => {
121+
root.render(<BaseTagsModal open onOpenChange={vi.fn()} knowledgeBaseId='knowledge-base-id' />)
122+
})
123+
124+
const copyButton = container.querySelector(
125+
'button[aria-label="Copy category tag ID"]'
126+
) as HTMLButtonElement
127+
const deleteButton = container.querySelector(
128+
'button[aria-label="Delete category tag"]'
129+
) as HTMLButtonElement
130+
131+
expect(copyButton).toBeTruthy()
132+
expect(deleteButton).toBeTruthy()
133+
expect(copyButton.querySelector('[data-icon="duplicate"]')).toBeTruthy()
134+
expect(
135+
copyButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING
136+
).toBeTruthy()
137+
138+
await act(async () => {
139+
copyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
140+
})
141+
142+
expect(mockCopy).toHaveBeenCalledWith('tag-definition-uuid')
143+
expect(mockRefetchTagUsage).not.toHaveBeenCalled()
144+
expect(container.textContent).toContain('Copied')
145+
expect(copyButton.querySelector('svg')?.classList.contains('text-[var(--text-success)]')).toBe(
146+
true
147+
)
148+
})
149+
})

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
ChipModalHeader,
1414
type ComboboxOption,
1515
handleKeyboardActivation,
16+
Tooltip,
17+
useCopyToClipboard,
1618
} from '@sim/emcn'
17-
import { Trash } from '@sim/emcn/icons'
19+
import { Check, Duplicate, Trash } from '@sim/emcn/icons'
1820
import { createLogger } from '@sim/logger'
1921
import type { TagUsageData } from '@/lib/api/contracts/knowledge'
2022
import {
@@ -97,6 +99,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
9799
displayName: '',
98100
fieldType: 'text',
99101
})
102+
const [copiedTagId, setCopiedTagId] = useState<string | null>(null)
103+
const { copied, copy } = useCopyToClipboard()
100104

101105
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(knowledgeBaseId, {
102106
enabled: open,
@@ -125,6 +129,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
125129
setViewDocumentsDialogOpen(true)
126130
}
127131

132+
const handleCopyTagId = async (tagId: string) => {
133+
if (await copy(tagId)) {
134+
setCopiedTagId(tagId)
135+
}
136+
}
137+
128138
const openTagCreator = () => {
129139
setCreateTagForm({
130140
displayName: '',
@@ -291,13 +301,38 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
291301
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
292302
</span>
293303
<div className='flex flex-shrink-0 items-center gap-1'>
304+
<Tooltip.Root>
305+
<Tooltip.Trigger asChild>
306+
<Button
307+
type='button'
308+
variant='ghost'
309+
onClick={(e) => {
310+
e.stopPropagation()
311+
void handleCopyTagId(tag.id)
312+
}}
313+
className='size-4 p-0 text-[var(--text-muted)]'
314+
aria-label={`Copy ${tag.displayName} tag ID`}
315+
>
316+
{copied && copiedTagId === tag.id ? (
317+
<Check className='size-3 text-[var(--text-success)]' />
318+
) : (
319+
<Duplicate className='size-3' />
320+
)}
321+
</Button>
322+
</Tooltip.Trigger>
323+
<Tooltip.Content side='top'>
324+
{copied && copiedTagId === tag.id ? 'Copied' : 'Copy tag ID'}
325+
</Tooltip.Content>
326+
</Tooltip.Root>
294327
<Button
328+
type='button'
295329
variant='ghost'
296330
onClick={(e) => {
297331
e.stopPropagation()
298332
handleDeleteTagClick(tag)
299333
}}
300334
className='size-4 p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-error)]'
335+
aria-label={`Delete ${tag.displayName} tag`}
301336
>
302337
<Trash className='size-3' />
303338
</Button>

0 commit comments

Comments
 (0)