Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
91ec8ef2a6 fix(kbtags): added time to date tag, improved ui ux throughout the kb 2026-01-30 20:37:48 -08:00
25 changed files with 1168 additions and 337 deletions

View File

@@ -27,16 +27,16 @@ All API responses include information about your workflow execution limits and u
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 150, // Sustained rate limit per minute
"maxBurst": 300, // Maximum burst capacity
"remaining": 298, // Current tokens available (up to maxBurst)
"resetAt": "..." // When tokens next refill
"requestsPerMinute": 60, // Sustained rate limit per minute
"maxBurst": 120, // Maximum burst capacity
"remaining": 118, // Current tokens available (up to maxBurst)
"resetAt": "..." // When tokens next refill
},
"async": {
"requestsPerMinute": 1000, // Sustained rate limit per minute
"maxBurst": 2000, // Maximum burst capacity
"remaining": 1998, // Current tokens available
"resetAt": "..." // When tokens next refill
"requestsPerMinute": 200, // Sustained rate limit per minute
"maxBurst": 400, // Maximum burst capacity
"remaining": 398, // Current tokens available
"resetAt": "..." // When tokens next refill
}
},
"usage": {
@@ -107,28 +107,28 @@ Query workflow execution logs with extensive filtering options.
}
],
"nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0",
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 298,
"resetAt": "2025-01-01T12:35:56.789Z"
},
"async": {
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 1998,
"resetAt": "2025-01-01T12:35:56.789Z"
}
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 60,
"maxBurst": 120,
"remaining": 118,
"resetAt": "2025-01-01T12:35:56.789Z"
},
"usage": {
"currentPeriodCost": 1.234,
"limit": 10,
"plan": "pro",
"isExceeded": false
"async": {
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 398,
"resetAt": "2025-01-01T12:35:56.789Z"
}
},
"usage": {
"currentPeriodCost": 1.234,
"limit": 10,
"plan": "pro",
"isExceeded": false
}
}
}
```
</Tab>
@@ -188,15 +188,15 @@ Retrieve detailed information about a specific log entry.
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 298,
"requestsPerMinute": 60,
"maxBurst": 120,
"remaining": 118,
"resetAt": "2025-01-01T12:35:56.789Z"
},
"async": {
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 1998,
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 398,
"resetAt": "2025-01-01T12:35:56.789Z"
}
},
@@ -477,10 +477,10 @@ The API uses a **token bucket algorithm** for rate limiting, providing fair usag
| Plan | Requests/Minute | Burst Capacity |
|------|-----------------|----------------|
| Free | 30 | 60 |
| Pro | 100 | 200 |
| Team | 200 | 400 |
| Enterprise | 500 | 1000 |
| Free | 10 | 20 |
| Pro | 30 | 60 |
| Team | 60 | 120 |
| Enterprise | 120 | 240 |
**How it works:**
- Tokens refill at `requestsPerMinute` rate

View File

@@ -170,16 +170,16 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
"rateLimit": {
"sync": {
"isLimited": false,
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 300,
"requestsPerMinute": 25,
"maxBurst": 50,
"remaining": 50,
"resetAt": "2025-09-08T22:51:55.999Z"
},
"async": {
"isLimited": false,
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 2000,
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 400,
"resetAt": "2025-09-08T22:51:56.155Z"
},
"authType": "api"
@@ -206,11 +206,11 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
Different subscription plans have different usage limits:
| Plan | Monthly Usage Included | Rate Limits (per minute) |
|------|------------------------|-------------------------|
| **Free** | $20 | 50 sync, 200 async |
| **Pro** | $20 (adjustable) | 150 sync, 1,000 async |
| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async |
| Plan | Monthly Usage Limit | Rate Limits (per minute) |
|------|-------------------|-------------------------|
| **Free** | $20 | 5 sync, 10 async |
| **Pro** | $100 | 10 sync, 50 async |
| **Team** | $500 (pooled) | 50 sync, 100 async |
| **Enterprise** | Custom | Custom |
## Billing Model

View File

@@ -202,7 +202,6 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -233,7 +232,6 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -262,7 +260,6 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -292,7 +289,6 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -325,7 +321,6 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -346,7 +341,6 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -380,7 +374,6 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -413,7 +406,6 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
})
@@ -427,4 +419,97 @@ describe('Knowledge Search Utils', () => {
expect(result).toEqual({})
})
})
describe('Date Filter Format Handling', () => {
it('should accept date-only format (YYYY-MM-DD) in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp format in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15T14:30:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with UTC timezone in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'gte',
value: '2024-01-15T14:30:00Z',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with timezone offset in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'lt',
value: '2024-01-15T14:30:00+05:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should support all date comparison operators', () => {
const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']
const validDateValue = '2024-01-15'
for (const operator of operators) {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator,
value: validDateValue,
}
expect(filter.operator).toBe(operator)
}
})
it('should support between operator with date range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01',
valueTo: '2024-12-31',
}
expect(filter.operator).toBe('between')
expect(filter.value).toBe('2024-01-01')
expect(filter.valueTo).toBe('2024-12-31')
})
it('should support between operator with timestamp range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01T00:00:00',
valueTo: '2024-12-31T23:59:59',
}
expect(filter.operator).toBe('between')
expect(filter.value).toMatch(/T\d{2}:\d{2}:\d{2}$/)
expect(filter.valueTo).toMatch(/T\d{2}:\d{2}:\d{2}$/)
})
})
})

View File

@@ -203,39 +203,74 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
}
}
// Handle date operators - expects YYYY-MM-DD format from frontend
// Handle date operators - accepts YYYY-MM-DD or ISO 8601 timestamp
if (fieldType === 'date') {
const dateStr = String(value)
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
const datetimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
// Validate format - accept date-only or timestamp
const isDateOnly = dateOnlyRegex.test(dateStr)
const isTimestamp = datetimeRegex.test(dateStr)
if (!isDateOnly && !isTimestamp) {
logger.debug(
`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss`
)
return null
}
// Use date comparison for date-only values, timestamp comparison for timestamps
const castType = isDateOnly ? '::date' : '::timestamp'
switch (operator) {
case 'eq':
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
case 'neq':
return sql`${column}::date != ${dateStr}::date`
return isDateOnly
? sql`${column}::date != ${dateStr}::date`
: sql`${column}::timestamp != ${dateStr}::timestamp`
case 'gt':
return sql`${column}::date > ${dateStr}::date`
return isDateOnly
? sql`${column}::date > ${dateStr}::date`
: sql`${column}::timestamp > ${dateStr}::timestamp`
case 'gte':
return sql`${column}::date >= ${dateStr}::date`
return isDateOnly
? sql`${column}::date >= ${dateStr}::date`
: sql`${column}::timestamp >= ${dateStr}::timestamp`
case 'lt':
return sql`${column}::date < ${dateStr}::date`
return isDateOnly
? sql`${column}::date < ${dateStr}::date`
: sql`${column}::timestamp < ${dateStr}::timestamp`
case 'lte':
return sql`${column}::date <= ${dateStr}::date`
return isDateOnly
? sql`${column}::date <= ${dateStr}::date`
: sql`${column}::timestamp <= ${dateStr}::timestamp`
case 'between':
if (valueTo !== undefined) {
const dateStrTo = String(valueTo)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
return sql`${column}::date = ${dateStr}::date`
const isToDateOnly = dateOnlyRegex.test(dateStrTo)
const isToTimestamp = datetimeRegex.test(dateStrTo)
if (!isToDateOnly && !isToTimestamp) {
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
}
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
// Use date comparison if both are date-only, otherwise use timestamp
if (isDateOnly && isToDateOnly) {
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
}
return sql`${column}::timestamp >= ${dateStr}::timestamp AND ${column}::timestamp <= ${dateStrTo}::timestamp`
}
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
default:
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
}
}

View File

@@ -146,6 +146,8 @@ export default function PlaygroundPage() {
const [isDarkMode, setIsDarkMode] = useState(false)
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
const [dateValue, setDateValue] = useState('')
const [dateTimeValue, setDateTimeValue] = useState('')
const [dateTimePreset, setDateTimePreset] = useState('2025-01-30T14:30:00')
const [dateRangeStart, setDateRangeStart] = useState('')
const [dateRangeEnd, setDateRangeEnd] = useState('')
const [tagItems, setTagItems] = useState<TagItem[]>([
@@ -708,6 +710,30 @@ export default function PlaygroundPage() {
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
</VariantRow>
<VariantRow label='with time (empty)'>
<div className='w-72'>
<DatePicker
value={dateTimeValue}
onChange={setDateTimeValue}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>
{dateTimeValue || 'No value'}
</span>
</VariantRow>
<VariantRow label='with time (preset)'>
<div className='w-72'>
<DatePicker
value={dateTimePreset}
onChange={setDateTimePreset}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateTimePreset}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-56'>
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />

View File

@@ -25,6 +25,10 @@ interface ChunkContextMenuProps {
* Empty space action (shown when right-clicking on empty space)
*/
onAddChunk?: () => void
/**
* View document tags (shown when right-clicking on empty space)
*/
onViewTags?: () => void
/**
* Whether the chunk is currently enabled
*/
@@ -75,6 +79,7 @@ export function ChunkContextMenu({
onToggleEnabled,
onDelete,
onAddChunk,
onViewTags,
isChunkEnabled = true,
hasChunk,
disableToggleEnabled = false,
@@ -181,17 +186,29 @@ export function ChunkContextMenu({
)}
</>
) : (
onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)
<>
{onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)}
{onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
onClose()
}}
>
View tags
</PopoverItem>
)}
</>
)}
</PopoverContent>
</Popover>

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Badge,
Button,
Combobox,
DatePicker,
@@ -384,7 +385,7 @@ export function DocumentTagsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
@@ -405,9 +406,9 @@ export function DocumentTagsModal({
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
<Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
</Badge>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{formatValueForDisplay(tag.value, tag.fieldType)}
@@ -419,9 +420,9 @@ export function DocumentTagsModal({
e.stopPropagation()
handleRemoveTag(index)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
@@ -526,7 +527,8 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
/>
) : (
<Input
@@ -679,7 +681,8 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
/>
) : (
<Input

View File

@@ -641,7 +641,7 @@ export function Document({
variant='default'
className='h-[32px] rounded-[6px]'
>
Tags
Document tags
</Button>
)}
<Tooltip.Root>
@@ -864,10 +864,7 @@ export function Document({
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
>
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<SearchHighlight
text={truncateContent(chunk.content, 150, searchQuery)}
searchQuery={searchQuery}
@@ -1152,6 +1149,7 @@ export function Document({
? () => setIsCreateChunkModalOpen(true)
: undefined
}
onViewTags={() => setShowTagsModal(true)}
disableToggleEnabled={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'}

View File

@@ -49,6 +49,7 @@ import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowl
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
import {
ActionBar,
AddDocumentsModal,
@@ -367,6 +368,8 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
const [documentForTags, setDocumentForTags] = useState<DocumentData | null>(null)
const {
isOpen: isContextMenuOpen,
@@ -525,7 +528,6 @@ export function KnowledgeBase({
const newEnabled = !document.enabled
// Optimistic update
updateDocument(docId, { enabled: newEnabled })
updateDocumentMutation(
@@ -536,7 +538,6 @@ export function KnowledgeBase({
},
{
onError: () => {
// Rollback on error
updateDocument(docId, { enabled: !newEnabled })
},
}
@@ -547,7 +548,6 @@ export function KnowledgeBase({
* Handles retrying a failed document processing
*/
const handleRetryDocument = (docId: string) => {
// Optimistic update
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
@@ -593,7 +593,6 @@ export function KnowledgeBase({
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
// Optimistic update
updateDocument(documentId, { filename: newName })
return new Promise<void>((resolve, reject) => {
@@ -609,7 +608,6 @@ export function KnowledgeBase({
resolve()
},
onError: (err) => {
// Rollback on error
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
@@ -973,7 +971,7 @@ export function KnowledgeBase({
variant='default'
className='h-[32px] rounded-[6px]'
>
Tags
Tag definitions
</Button>
)}
<Tooltip.Root>
@@ -1221,17 +1219,9 @@ export function KnowledgeBase({
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={doc.filename}
>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{doc.filename}</Tooltip.Content>
</Tooltip.Root>
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
</div>
</TableCell>
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
@@ -1556,6 +1546,22 @@ export function KnowledgeBase({
/>
)}
{/* Document Tags Modal */}
{documentForTags && (
<DocumentTagsModal
open={showDocumentTagsModal}
onOpenChange={setShowDocumentTagsModal}
knowledgeBaseId={id}
documentId={documentForTags.id}
documentData={documentForTags}
onDocumentUpdate={(updates) => {
Object.entries(updates).forEach(([key, value]) => {
updateDocument(documentForTags.id, { [key]: value })
})
}}
/>
)}
<ActionBar
selectedCount={selectedDocuments.size}
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
@@ -1624,13 +1630,8 @@ export function KnowledgeBase({
onViewTags={
contextMenuDocument && selectedDocuments.size === 1
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
docName: contextMenuDocument.filename || 'Document',
})
router.push(
`/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
)
setDocumentForTags(contextMenuDocument)
setShowDocumentTagsModal(true)
}
: undefined
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -22,7 +22,12 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
import {
type TagUsageData,
useCreateTagDefinition,
useDeleteTagDefinition,
useTagUsageQuery,
} from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal')
@@ -33,13 +38,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
boolean: 'Boolean',
}
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
@@ -91,45 +89,23 @@ interface BaseTagsModalProps {
}
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const createTagMutation = useCreateTagDefinition()
const deleteTagMutation = useDeleteTagDefinition()
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(
open ? knowledgeBaseId : null
)
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
const fetchTagUsage = useCallback(async () => {
if (!knowledgeBaseId) return
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
if (result.success) {
setTagUsageData(result.data)
}
} catch (error) {
logger.error('Error fetching tag usage:', error)
}
}, [knowledgeBaseId])
useEffect(() => {
if (open) {
fetchTagUsage()
}
}, [open, fetchTagUsage])
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
@@ -143,13 +119,29 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
setDeleteTagDialogOpen(true)
const { data: freshTagUsage } = await refetchTagUsage()
const tagUsage = freshTagUsage?.find((usage) => usage.tagSlot === tag.tagSlot)
const documentCount = tagUsage?.documentCount ?? 0
if (documentCount === 0) {
try {
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: tag.id,
})
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
}
} else {
setDeleteTagDialogOpen(true)
}
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
await refetchTagUsage()
setViewDocumentsDialogOpen(true)
}
@@ -219,8 +211,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
fieldType: createTagForm.fieldType,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
displayName: '',
fieldType: 'text',
@@ -240,8 +230,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
tagDefinitionId: selectedTag.id,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setDeleteTagDialogOpen(false)
setSelectedTag(null)
} catch (error) {
@@ -265,7 +253,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
@@ -315,9 +303,10 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Tag</span>
</Button>
</div>
</div>
@@ -331,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
className='w-full'
>
Add Tag
Add tag definition
</Button>
)}
@@ -415,7 +404,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* Delete Tag Confirmation Dialog */}
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>
@@ -458,7 +447,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* View Documents Dialog */}
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
@@ -15,6 +15,7 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const logger = createLogger('KnowledgeHeader')
@@ -55,43 +56,23 @@ interface Workspace {
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const updateKnowledgeBase = useUpdateKnowledgeBase()
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
!!options?.knowledgeBaseId
)
useEffect(() => {
if (!options?.knowledgeBaseId) return
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoadingWorkspaces(false)
}
}
fetchWorkspaces()
}, [options?.knowledgeBaseId])
const workspaces = useMemo<Workspace[]>(
() =>
allWorkspaces
.filter((ws) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions as 'admin' | 'write' | 'read',
})),
[allWorkspaces]
)
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return

View File

@@ -1,8 +1,11 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useKnowledgeBasesQuery } from '@/hooks/queries/knowledge'
import { useRecentLogs } from '@/hooks/queries/logs'
import { useTemplates } from '@/hooks/queries/templates'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -107,10 +110,10 @@ export interface MentionDataReturn {
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => void
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
ensureTemplatesLoaded: () => void
ensureLogsLoaded: () => void
}
/**
@@ -128,8 +131,20 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const [pastChats, setPastChats] = useState<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false)
const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery(
workspaceId,
{ enabled: shouldLoadKnowledge }
)
const knowledgeBases = useMemo<KnowledgeItem[]>(() => {
const sorted = [...knowledgeData].sort((a, b) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
return sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))
}, [knowledgeData])
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
@@ -138,11 +153,39 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
setBlocksList([])
}, [config.allowedIntegrations])
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
const [shouldLoadTemplates, setShouldLoadTemplates] = useState(false)
const { data: templatesData, isLoading: isLoadingTemplates } = useTemplates(
{ limit: 50, offset: 0 },
{ enabled: shouldLoadTemplates }
)
const [logsList, setLogsList] = useState<LogItem[]>([])
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const templatesList = useMemo<TemplateItem[]>(() => {
const items = templatesData?.data ?? []
return items
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a, b) => b.stars - a.stars)
}, [templatesData])
const [shouldLoadLogs, setShouldLoadLogs] = useState(false)
const { data: logsData = [], isLoading: isLoadingLogs } = useRecentLogs(workspaceId, 50, {
enabled: shouldLoadLogs,
})
const logsList = useMemo<LogItem[]>(
() =>
logsData.map((l) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
})),
[logsData]
)
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
@@ -191,7 +234,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
}
try {
// Fetch current blocks from store
const workflowStoreBlocks = useWorkflowStore.getState().blocks
const { registry: blockRegistry } = await import('@/blocks/registry')
@@ -248,25 +290,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures knowledge bases are loaded
*/
const ensureKnowledgeLoaded = useCallback(async () => {
if (isLoadingKnowledge || knowledgeBases.length > 0) return
try {
setIsLoadingKnowledge(true)
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const sorted = [...items].sort((a: any, b: any) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
} catch {
} finally {
setIsLoadingKnowledge(false)
const ensureKnowledgeLoaded = useCallback(() => {
if (!shouldLoadKnowledge) {
setShouldLoadKnowledge(true)
}
}, [isLoadingKnowledge, knowledgeBases.length, workspaceId])
}, [shouldLoadKnowledge])
/**
* Ensures blocks are loaded
@@ -319,55 +347,22 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures templates are loaded
*/
const ensureTemplatesLoaded = useCallback(async () => {
if (isLoadingTemplates || templatesList.length > 0) return
try {
setIsLoadingTemplates(true)
const resp = await fetch('/api/templates?limit=50&offset=0')
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a: any, b: any) => b.stars - a.stars)
setTemplatesList(mapped)
} catch {
} finally {
setIsLoadingTemplates(false)
const ensureTemplatesLoaded = useCallback(() => {
if (!shouldLoadTemplates) {
setShouldLoadTemplates(true)
}
}, [isLoadingTemplates, templatesList.length])
}, [shouldLoadTemplates])
/**
* Ensures logs are loaded
*/
const ensureLogsLoaded = useCallback(async () => {
if (isLoadingLogs || logsList.length > 0) return
try {
setIsLoadingLogs(true)
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items.map((l: any) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
}))
setLogsList(mapped)
} catch {
} finally {
setIsLoadingLogs(false)
const ensureLogsLoaded = useCallback(() => {
if (!shouldLoadLogs) {
setShouldLoadLogs(true)
}
}, [isLoadingLogs, logsList.length, workspaceId])
}, [shouldLoadLogs])
return {
// State
pastChats,
isLoadingPastChats,
workflows,
@@ -382,8 +377,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
isLoadingLogs,
workflowBlocks,
isLoadingWorkflowBlocks,
// Operations
ensurePastChatsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,

View File

@@ -13,8 +13,8 @@ import { SlackMonoIcon } from '@/components/icons'
import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card'
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '150 runs per minute (sync)' },
{ icon: Clock, text: '1,000 runs per minute (async)' },
{ icon: Zap, text: '25 runs per minute (sync)' },
{ icon: Clock, text: '200 runs per minute (async)' },
{ icon: HardDrive, text: '50GB file storage' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },
@@ -22,8 +22,8 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [
]
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '300 runs per minute (sync)' },
{ icon: Clock, text: '2,500 runs per minute (async)' },
{ icon: Zap, text: '75 runs per minute (sync)' },
{ icon: Clock, text: '500 runs per minute (async)' },
{ icon: HardDrive, text: '500GB file storage (pooled)' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },

View File

@@ -13,8 +13,8 @@ interface FreeTierUpgradeEmailProps {
const proFeatures = [
{ label: '$20/month', desc: 'in credits included' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '1,000 runs/min', desc: 'async executions' },
{ label: '25 runs/min', desc: 'sync executions' },
{ label: '200 runs/min', desc: 'async executions' },
{ label: '50GB storage', desc: 'for files & assets' },
{ label: 'Unlimited', desc: 'workspaces & invites' },
]

View File

@@ -28,7 +28,7 @@ const checkboxVariants = cva(
'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
].join(' '),
{
variants: {

View File

@@ -20,7 +20,7 @@ import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -460,7 +460,7 @@ const Combobox = memo(
<Input
ref={inputRef}
className={cn(
'w-full pr-[40px] font-medium transition-colors hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'w-full pr-[40px] font-medium transition-colors',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-[28px]',
className

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -82,10 +82,12 @@ interface DatePickerBaseProps
interface DatePickerSingleProps extends DatePickerBaseProps {
/** Selection mode */
mode?: 'single'
/** Current selected date value (YYYY-MM-DD string or Date) */
/** Current selected date value (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss string, or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD format */
/** Callback when date changes, returns YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format */
onChange?: (value: string) => void
/** When true, shows time picker after date selection and outputs ISO 8601 format */
showTime?: boolean
/** Not used in single mode */
startDate?: never
/** Not used in single mode */
@@ -177,14 +179,91 @@ const MONTHS_SHORT = [
/**
* Formats a date for display in the trigger button.
* If time is provided, formats as "Jan 30, 2026 at 2:30 PM"
*/
function formatDateForDisplay(date: Date | null): string {
function formatDateForDisplay(date: Date | null, time?: string | null): string {
if (!date) return ''
return date.toLocaleDateString('en-US', {
const dateStr = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (time) {
return `${dateStr} at ${formatDisplayTime(time)}`
}
return dateStr
}
/**
* Converts a 24h time string to 12h display format with AM/PM.
*/
function formatDisplayTime(time: string): string {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
/**
* Converts 12h time components to 24h format string.
*/
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
/**
* Parses a 24h time string into 12h components.
* Returns default 12:00 PM if no time provided (for UI display only).
*/
function parseTimeComponents(time: string | null): {
hour: string
minute: string
ampm: 'AM' | 'PM'
} {
if (!time) return { hour: '12', minute: '00', ampm: 'PM' }
const [hours, minutes] = time.split(':')
const hour24 = Number.parseInt(hours, 10)
const isAM = hour24 < 12
return {
hour: (hour24 % 12 || 12).toString(),
minute: minutes || '00',
ampm: isAM ? 'AM' : 'PM',
}
}
/**
* Checks if a value contains time information.
*/
function valueHasTime(value: string | Date | undefined): boolean {
if (!value) return false
if (value instanceof Date) {
// Check if time is not midnight (default)
return value.getHours() !== 0 || value.getMinutes() !== 0
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm
return /T\d{2}:\d{2}/.test(value)
}
/**
* Extracts time from a datetime string or Date object.
* Returns HH:mm format or null if no time present.
*/
function extractTimeFromValue(value: string | Date | undefined): string | null {
if (!value) return null
if (value instanceof Date) {
// Only return time if it's not midnight (which could be default)
if (value.getHours() === 0 && value.getMinutes() === 0) return null
return `${value.getHours().toString().padStart(2, '0')}:${value.getMinutes().toString().padStart(2, '0')}`
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm:ss
const match = value.match(/T(\d{2}):(\d{2})/)
if (match) {
return `${match[1]}:${match[2]}`
}
return null
}
/**
@@ -228,12 +307,16 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
/**
* Formats a date as YYYY-MM-DD string.
* Formats a date as YYYY-MM-DD string, optionally with time as YYYY-MM-DDTHH:mm:ss.
*/
function formatDateAsString(year: number, month: number, day: number): string {
function formatDateAsString(year: number, month: number, day: number, time?: string): string {
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
const dateStr = `${year}-${m}-${d}`
if (time) {
return `${dateStr}T${time}:00`
}
return dateStr
}
/**
@@ -498,6 +581,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const {
value: _value,
onChange: _onChange,
showTime: _showTime,
startDate: _startDate,
endDate: _endDate,
onRangeChange: _onRangeChange,
@@ -507,6 +591,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
} = rest as any
const isRangeMode = props.mode === 'range'
const showTime = !isRangeMode && (props as DatePickerSingleProps).showTime === true
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = React.useState(false)
@@ -524,6 +609,37 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const selectedDate = !isRangeMode ? parseDate(props.value) : null
// Time state for showTime mode
// Track whether the incoming value has time
const valueTimeInfo = React.useMemo(() => {
if (!showTime) return { hasTime: false, time: null }
const time = extractTimeFromValue(props.value)
return { hasTime: time !== null, time }
}, [showTime, props.value])
const parsedTime = React.useMemo(
() => parseTimeComponents(valueTimeInfo.time),
[valueTimeInfo.time]
)
const [hour, setHour] = React.useState(parsedTime.hour)
const [minute, setMinute] = React.useState(parsedTime.minute)
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsedTime.ampm)
// Track whether user has explicitly set time (either from value or interaction)
const [timeWasSet, setTimeWasSet] = React.useState(valueTimeInfo.hasTime)
const hourInputRef = React.useRef<HTMLInputElement>(null)
// Sync time state when value changes
React.useEffect(() => {
if (showTime) {
const time = extractTimeFromValue(props.value)
const newParsed = parseTimeComponents(time)
setHour(newParsed.hour)
setMinute(newParsed.minute)
setAmpm(newParsed.ampm)
setTimeWasSet(time !== null)
}
}, [showTime, props.value])
const initialStart = isRangeMode ? parseDate(props.startDate) : null
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
@@ -566,17 +682,186 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [isRangeMode, selectedDate])
/**
* Gets the current time string in 24h format.
*/
const getCurrentTimeString = React.useCallback(() => {
const h = Number.parseInt(hour) || 12
const m = Number.parseInt(minute) || 0
return formatStorageTime(h, m, ampm)
}, [hour, minute, ampm])
/**
* Handles selection of a specific day in single mode.
*/
const handleSelectDateSingle = React.useCallback(
(day: number) => {
if (!isRangeMode && props.onChange) {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
setOpen(false)
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(formatDateAsString(viewYear, viewMonth, day, getCurrentTimeString()))
} else {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
if (!showTime) {
setOpen(false)
}
}
}
},
[isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
[
isRangeMode,
viewYear,
viewMonth,
props.onChange,
setOpen,
showTime,
getCurrentTimeString,
timeWasSet,
]
)
/**
* Handles hour input change.
*/
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setHour(val)
}, [])
/**
* Handles hour input blur - validates and updates value.
*/
const handleHourBlur = React.useCallback(() => {
const numVal = Number.parseInt(hour) || 12
const clamped = Math.min(12, Math.max(1, numVal))
setHour(clamped.toString())
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(clamped, Number.parseInt(minute) || 0, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [hour, minute, ampm, selectedDate, props.onChange, showTime])
/**
* Handles minute input change.
*/
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setMinute(val)
}, [])
/**
* Handles minute input blur - validates and updates value.
*/
const handleMinuteBlur = React.useCallback(() => {
const numVal = Number.parseInt(minute) || 0
const clamped = Math.min(59, Math.max(0, numVal))
setMinute(clamped.toString().padStart(2, '0'))
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(Number.parseInt(hour) || 12, clamped, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [minute, hour, ampm, selectedDate, props.onChange, showTime])
/**
* Handles AM/PM toggle.
*/
const handleAmpmChange = React.useCallback(
(newAmpm: 'AM' | 'PM') => {
setAmpm(newAmpm)
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(
Number.parseInt(hour) || 12,
Number.parseInt(minute) || 0,
newAmpm
)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
},
[hour, minute, selectedDate, props.onChange, showTime]
)
/**
* Handles keyboard navigation in hour input (Enter, ArrowUp, ArrowDown).
*/
const handleHourKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num >= 12 ? 1 : num + 1
return next.toString()
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num <= 1 ? 12 : num - 1
return next.toString()
})
}
},
[setOpen, timeWasSet]
)
/**
* Handles keyboard navigation in minute input (Enter, ArrowUp, ArrowDown).
*/
const handleMinuteKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num >= 59 ? 0 : num + 1
return next.toString().padStart(2, '0')
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num <= 0 ? 59 : num - 1
return next.toString().padStart(2, '0')
})
}
},
[setOpen, timeWasSet]
)
/**
@@ -640,16 +925,31 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
/**
* Selects today's date (single mode only).
* Preserves existing time if set, otherwise outputs date only.
*/
const handleSelectToday = React.useCallback(() => {
if (!isRangeMode && props.onChange) {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false)
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(
formatDateAsString(
now.getFullYear(),
now.getMonth(),
now.getDate(),
getCurrentTimeString()
)
)
} else {
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
if (!showTime) {
setOpen(false)
}
}
}
}, [isRangeMode, props.onChange, setOpen])
}, [isRangeMode, props.onChange, setOpen, showTime, getCurrentTimeString, timeWasSet])
/**
* Applies the selected range (range mode only).
@@ -710,9 +1010,11 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [disabled, open, setOpen])
// Only show time in display if it was explicitly set
const displayTime = showTime && timeWasSet ? getCurrentTimeString() : null
const displayValue = isRangeMode
? formatDateRangeForDisplay(initialStart, initialEnd)
: formatDateForDisplay(selectedDate)
: formatDateForDisplay(selectedDate, displayTime)
const calendarContent = isRangeMode ? (
<>
@@ -783,12 +1085,81 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
onNextMonth={goToNextMonth}
/>
{/* Today Button */}
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
{/* Time Picker (when showTime is enabled) */}
{showTime && (
<div className='flex items-center justify-center gap-[6px] border-[var(--border-1)] border-t px-[12px] py-[10px]'>
<span className='font-medium text-[12px] text-[var(--text-muted)]'>Time:</span>
<input
ref={hourInputRef}
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={hour}
onChange={(e) => {
handleHourChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleHourBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleHourKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
<input
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={minute}
onChange={(e) => {
handleMinuteChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleMinuteBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleMinuteKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<div
className={cn(
'ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]',
!timeWasSet && 'opacity-50'
)}
>
{(['AM', 'PM'] as const).map((period) => (
<button
key={period}
type='button'
onClick={() => handleAmpmChange(period)}
className={cn(
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
timeWasSet && ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
)}
>
{period}
</button>
))}
</div>
</div>
)}
{/* Today Button (only shown when time picker is not enabled) */}
{!showTime && (
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
)}
</>
)

View File

@@ -17,6 +17,8 @@ export const knowledgeKeys = {
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
tagDefinitions: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
tagUsage: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
documents: (knowledgeBaseId: string, paramsKey: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
document: (knowledgeBaseId: string, documentId: string) =>
@@ -910,6 +912,38 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) {
})
}
export interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
export async function fetchTagUsage(knowledgeBaseId: string): Promise<TagUsageData[]> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error(`Failed to fetch tag usage: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch tag usage')
}
return Array.isArray(result.data) ? result.data : []
}
export function useTagUsageQuery(knowledgeBaseId?: string | null) {
return useQuery({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''),
queryFn: () => fetchTagUsage(knowledgeBaseId as string),
enabled: Boolean(knowledgeBaseId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
export interface CreateTagDefinitionParams {
knowledgeBaseId: string
displayName: string
@@ -968,6 +1002,9 @@ export function useCreateTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}
@@ -1006,6 +1043,9 @@ export function useDeleteTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}

View File

@@ -15,6 +15,8 @@ export const logKeys = {
lists: () => [...logKeys.all, 'list'] as const,
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
[...logKeys.lists(), workspaceId ?? '', filters] as const,
recent: (workspaceId: string | undefined, limit: number) =>
[...logKeys.all, 'recent', workspaceId ?? '', limit] as const,
details: () => [...logKeys.all, 'detail'] as const,
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
stats: (workspaceId: string | undefined, filters: object) =>
@@ -248,3 +250,57 @@ export function useExecutionSnapshot(executionId: string | undefined) {
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
})
}
/**
* Simple recent logs data for lightweight use cases (e.g., mention suggestions)
*/
export interface RecentLog {
id: string
executionId?: string
level: string
trigger: string | null
createdAt: string
workflow?: {
name?: string
title?: string
}
workflowName?: string
}
async function fetchRecentLogs(workspaceId: string, limit: number): Promise<RecentLog[]> {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
params.set('limit', limit.toString())
params.set('details', 'full')
const response = await fetch(`/api/logs?${params.toString()}`)
if (!response.ok) {
throw new Error('Failed to fetch recent logs')
}
const data = await response.json()
return Array.isArray(data?.data) ? data.data : []
}
interface UseRecentLogsOptions {
enabled?: boolean
}
/**
* Hook for fetching recent logs with minimal filtering.
* Useful for lightweight use cases like mention suggestions.
*/
export function useRecentLogs(
workspaceId: string | undefined,
limit = 50,
options?: UseRecentLogsOptions
) {
return useQuery({
queryKey: logKeys.recent(workspaceId, limit),
queryFn: () => fetchRecentLogs(workspaceId as string, limit),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -161,14 +161,14 @@ export const env = createEnv({
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
// Knowledge Base Processing Configuration - Shared across all processing methods
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)

View File

@@ -28,24 +28,24 @@ function createBucketConfig(ratePerMinute: number, burstMultiplier = 2): TokenBu
export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
free: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 50),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 200),
apiEndpoint: createBucketConfig(30),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50),
apiEndpoint: createBucketConfig(10),
},
pro: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 150),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 1000),
apiEndpoint: createBucketConfig(100),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200),
apiEndpoint: createBucketConfig(30),
},
team: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 300),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 2500),
apiEndpoint: createBucketConfig(200),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500),
apiEndpoint: createBucketConfig(60),
},
enterprise: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 600),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 5000),
apiEndpoint: createBucketConfig(500),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000),
apiEndpoint: createBucketConfig(120),
},
}

View File

@@ -103,7 +103,7 @@ export function getPlaceholderForFieldType(fieldType: string): string {
case 'number':
return 'Enter number'
case 'date':
return 'YYYY-MM-DD'
return 'YYYY-MM-DD or YYYY-MM-DD HH:mm'
default:
return 'Enter value'
}

View File

@@ -0,0 +1,187 @@
/**
* Tests for knowledge tag validation utility functions
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseBooleanValue, parseDateValue, parseNumberValue, validateTagValue } from './utils'
describe('Knowledge Tag Utils', () => {
describe('validateTagValue', () => {
describe('boolean validation', () => {
it('should accept "true" as valid boolean', () => {
expect(validateTagValue('isActive', 'true', 'boolean')).toBeNull()
})
it('should accept "false" as valid boolean', () => {
expect(validateTagValue('isActive', 'false', 'boolean')).toBeNull()
})
it('should accept case-insensitive boolean values', () => {
expect(validateTagValue('isActive', 'TRUE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'FALSE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'True', 'boolean')).toBeNull()
})
it('should reject invalid boolean values', () => {
const result = validateTagValue('isActive', 'yes', 'boolean')
expect(result).toContain('expects a boolean value')
})
})
describe('number validation', () => {
it('should accept valid integers', () => {
expect(validateTagValue('count', '42', 'number')).toBeNull()
expect(validateTagValue('count', '-10', 'number')).toBeNull()
expect(validateTagValue('count', '0', 'number')).toBeNull()
})
it('should accept valid decimals', () => {
expect(validateTagValue('price', '19.99', 'number')).toBeNull()
expect(validateTagValue('price', '-3.14', 'number')).toBeNull()
})
it('should reject non-numeric values', () => {
const result = validateTagValue('count', 'abc', 'number')
expect(result).toContain('expects a number value')
})
})
describe('date validation', () => {
it('should accept valid YYYY-MM-DD format', () => {
expect(validateTagValue('createdAt', '2024-01-15', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-12-31', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp without timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T00:00:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T23:59:59', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with seconds omitted', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with UTC timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00Z', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with timezone offset', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00+05:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T14:30:00-08:00', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with milliseconds', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00.123Z', 'date')).toBeNull()
})
it('should reject invalid date format', () => {
const result = validateTagValue('createdAt', '01/15/2024', 'date')
expect(result).toContain('expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format')
})
it('should reject invalid date values like Feb 31', () => {
const result = validateTagValue('createdAt', '2024-02-31', 'date')
expect(result).toContain('invalid date')
})
it('should reject invalid time values', () => {
const result = validateTagValue('createdAt', '2024-01-15T25:00:00', 'date')
expect(result).toContain('invalid time')
})
it('should reject invalid minute values', () => {
const result = validateTagValue('createdAt', '2024-01-15T12:61:00', 'date')
expect(result).toContain('invalid time')
})
})
describe('text/default validation', () => {
it('should accept any string for text type', () => {
expect(validateTagValue('name', 'anything goes', 'text')).toBeNull()
expect(validateTagValue('name', '123', 'text')).toBeNull()
expect(validateTagValue('name', '', 'text')).toBeNull()
})
})
})
describe('parseDateValue', () => {
it('should parse valid YYYY-MM-DD format', () => {
const result = parseDateValue('2024-01-15')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0) // January is 0
expect(result?.getDate()).toBe(15)
})
it('should parse valid ISO 8601 timestamp', () => {
const result = parseDateValue('2024-01-15T14:30:00')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0)
expect(result?.getDate()).toBe(15)
expect(result?.getHours()).toBe(14)
expect(result?.getMinutes()).toBe(30)
})
it('should parse valid ISO 8601 timestamp with UTC timezone', () => {
const result = parseDateValue('2024-01-15T14:30:00Z')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
})
it('should return null for invalid format', () => {
expect(parseDateValue('01/15/2024')).toBeNull()
expect(parseDateValue('invalid')).toBeNull()
expect(parseDateValue('')).toBeNull()
})
it('should return null for invalid date values', () => {
expect(parseDateValue('2024-02-31')).toBeNull() // Feb 31 doesn't exist
expect(parseDateValue('2024-13-01')).toBeNull() // Month 13 doesn't exist
})
})
describe('parseNumberValue', () => {
it('should parse valid integers', () => {
expect(parseNumberValue('42')).toBe(42)
expect(parseNumberValue('-10')).toBe(-10)
expect(parseNumberValue('0')).toBe(0)
})
it('should parse valid decimals', () => {
expect(parseNumberValue('19.99')).toBe(19.99)
expect(parseNumberValue('-3.14')).toBeCloseTo(-3.14)
})
it('should return null for non-numeric strings', () => {
expect(parseNumberValue('abc')).toBeNull()
})
it('should return 0 for empty string (JavaScript Number behavior)', () => {
expect(parseNumberValue('')).toBe(0)
})
})
describe('parseBooleanValue', () => {
it('should parse "true" to true', () => {
expect(parseBooleanValue('true')).toBe(true)
expect(parseBooleanValue('TRUE')).toBe(true)
expect(parseBooleanValue(' true ')).toBe(true)
})
it('should parse "false" to false', () => {
expect(parseBooleanValue('false')).toBe(false)
expect(parseBooleanValue('FALSE')).toBe(false)
expect(parseBooleanValue(' false ')).toBe(false)
})
it('should return null for invalid values', () => {
expect(parseBooleanValue('yes')).toBeNull()
expect(parseBooleanValue('no')).toBeNull()
expect(parseBooleanValue('1')).toBeNull()
expect(parseBooleanValue('')).toBeNull()
})
})
})

View File

@@ -1,3 +1,14 @@
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
const ISO_WITH_TZ_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
/**
* Check if a string is a valid date format (YYYY-MM-DD or ISO 8601 timestamp)
*/
function isValidDateFormat(value: string): boolean {
return DATE_ONLY_REGEX.test(value) || DATETIME_REGEX.test(value) || ISO_WITH_TZ_REGEX.test(value)
}
/**
* Validate a tag value against its expected field type
* Returns an error message if invalid, or null if valid
@@ -21,16 +32,35 @@ export function validateTagValue(tagName: string, value: string, fieldType: stri
return null
}
case 'date': {
// Check format first
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
// Check format first - accept YYYY-MM-DD or ISO 8601 datetime
if (!isValidDateFormat(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format, but received "${value}"`
}
// Extract date parts for validation
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return `Tag "${tagName}" has an invalid date: "${value}"`
}
// If timestamp is included, validate time components
if (stringValue.includes('T')) {
const timePart = stringValue.split('T')[1]
// Extract hours and minutes, ignoring timezone
const timeMatch = timePart.match(/^(\d{2}):(\d{2})/)
if (timeMatch) {
const hours = Number.parseInt(timeMatch[1], 10)
const minutes = Number.parseInt(timeMatch[2], 10)
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return `Tag "${tagName}" has an invalid time: "${value}"`
}
}
}
return null
}
default:
@@ -56,25 +86,44 @@ export function parseNumberValue(value: string): number | null {
}
/**
* Parse a string to Date with strict YYYY-MM-DD validation
* Parse a string to Date with validation for YYYY-MM-DD or ISO 8601 timestamp
* Returns null if invalid format or invalid date
*/
export function parseDateValue(value: string): Date | null {
const stringValue = String(value).trim()
// Must be YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
// Must be valid date format
if (!isValidDateFormat(stringValue)) {
return null
}
// Extract date parts
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
// First check date-only validity
const testDate = new Date(year, month - 1, day)
if (
testDate.getFullYear() !== year ||
testDate.getMonth() !== month - 1 ||
testDate.getDate() !== day
) {
return null
}
return date
// If timestamp is included, parse with time
if (stringValue.includes('T')) {
// Use native Date parsing for ISO strings
const date = new Date(stringValue)
if (Number.isNaN(date.getTime())) {
return null
}
return date
}
// Date-only: return date at midnight local time
return new Date(year, month - 1, day)
}
/**

View File

@@ -125,8 +125,8 @@ app:
# Rate Limiting Configuration (per minute)
RATE_LIMIT_WINDOW_MS: "60000" # Rate limit window duration (1 minute)
RATE_LIMIT_FREE_SYNC: "50" # Sync API executions per minute
RATE_LIMIT_FREE_ASYNC: "200" # Async API executions per minute
RATE_LIMIT_FREE_SYNC: "10" # Sync API executions per minute
RATE_LIMIT_FREE_ASYNC: "50" # Async API executions per minute
# UI Branding & Whitelabeling Configuration
NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name