spine-framework-cortex 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- package/seed/types.json +352 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { apiFetch } from '@core/lib/api'
|
|
3
|
+
import { Button } from '@core/components/ui/button'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@core/components/ui/card'
|
|
5
|
+
import { Progress } from '@core/components/ui/progress'
|
|
6
|
+
import { Alert, AlertDescription } from '@core/components/ui/alert'
|
|
7
|
+
import { Badge } from '@core/components/ui/badge'
|
|
8
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
9
|
+
|
|
10
|
+
interface IngestionResponse {
|
|
11
|
+
success: boolean
|
|
12
|
+
items_created: number
|
|
13
|
+
items_updated: number
|
|
14
|
+
embeddings_generated: number
|
|
15
|
+
errors: string[]
|
|
16
|
+
skipped: string[]
|
|
17
|
+
item_ids?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface IngestionStats {
|
|
21
|
+
total: number
|
|
22
|
+
processed: number
|
|
23
|
+
created: number
|
|
24
|
+
updated: number
|
|
25
|
+
errors: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Step = 'idle' | 'chunks_loaded' | 'ingestion_complete' | 'embeddings_complete'
|
|
29
|
+
|
|
30
|
+
export default function KBIngestionPage() {
|
|
31
|
+
const [currentStep, setCurrentStep] = useState<Step>('idle')
|
|
32
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
33
|
+
const [chunks, setChunks] = useState<any[]>([])
|
|
34
|
+
const [stats, setStats] = useState<IngestionStats | null>(null)
|
|
35
|
+
const [error, setError] = useState<string | null>(null)
|
|
36
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
37
|
+
const [ingestFrom, setIngestFrom] = useState<string>('')
|
|
38
|
+
const [ingestTo, setIngestTo] = useState<string>('')
|
|
39
|
+
const [ingestedItemIds, setIngestedItemIds] = useState<string[]>([])
|
|
40
|
+
|
|
41
|
+
const addLog = (message: string) => {
|
|
42
|
+
const timestamp = new Date().toLocaleTimeString()
|
|
43
|
+
setLogs(prev => [...prev, `[${timestamp}] ${message}`])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const loadChunks = async (): Promise<any[]> => {
|
|
47
|
+
try {
|
|
48
|
+
const response = await apiFetch('/.netlify/functions/custom_cortex-chunks', {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
57
|
+
throw new Error(`Failed to load chunks: ${errorData.error || response.statusText}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = await response.json()
|
|
61
|
+
return data.data?.chunks || []
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(`Could not load chunks: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ingestChunks = async (chunks: any[]): Promise<IngestionResponse> => {
|
|
68
|
+
const response = await apiFetch('/api/custom_kb-ingestion?action=ingest', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
chunks,
|
|
75
|
+
force_update: false
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
81
|
+
throw new Error(`Ingestion failed: ${errorData.error || response.statusText}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const json = await response.json()
|
|
85
|
+
return json.data ?? json
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const generateEmbeddings = async (itemIds: string[]): Promise<IngestionResponse> => {
|
|
89
|
+
const response = await apiFetch('/api/custom_kb-embeddings?action=generate', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
item_ids: itemIds,
|
|
96
|
+
vector_types: ['semantic', 'structure'],
|
|
97
|
+
force_regenerate: false
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
103
|
+
throw new Error(`Embedding generation failed: ${errorData.error || response.statusText}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const json = await response.json()
|
|
107
|
+
return json.data ?? json
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const handleLoadChunks = async () => {
|
|
111
|
+
setIsLoading(true)
|
|
112
|
+
setError(null)
|
|
113
|
+
setLogs([])
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
addLog('Loading chunks from file...')
|
|
117
|
+
const loadedChunks = await loadChunks()
|
|
118
|
+
addLog(`Loaded ${loadedChunks.length} chunks`)
|
|
119
|
+
setChunks(loadedChunks)
|
|
120
|
+
setCurrentStep('chunks_loaded')
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
|
123
|
+
setError(errorMessage)
|
|
124
|
+
addLog(`Error: ${errorMessage}`)
|
|
125
|
+
} finally {
|
|
126
|
+
setIsLoading(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleIngestChunks = async () => {
|
|
131
|
+
setIsLoading(true)
|
|
132
|
+
setError(null)
|
|
133
|
+
setLogs([])
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Parse range inputs
|
|
137
|
+
const from = parseInt(ingestFrom) || 1
|
|
138
|
+
const to = parseInt(ingestTo) || chunks.length
|
|
139
|
+
|
|
140
|
+
// Validate range
|
|
141
|
+
if (from < 1 || to < 1 || from > to || from > chunks.length || to > chunks.length) {
|
|
142
|
+
throw new Error(`Invalid range: Please enter values between 1 and ${chunks.length}, with from ≤ to`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Get the subset of chunks (convert to 0-based index)
|
|
146
|
+
const selectedChunks = chunks.slice(from - 1, to)
|
|
147
|
+
const recordCount = selectedChunks.length
|
|
148
|
+
|
|
149
|
+
addLog(`Starting ingestion of records ${from} through ${to} (${recordCount} records)...`)
|
|
150
|
+
const response = await ingestChunks(selectedChunks)
|
|
151
|
+
|
|
152
|
+
const newStats: IngestionStats = {
|
|
153
|
+
total: recordCount,
|
|
154
|
+
processed: recordCount,
|
|
155
|
+
created: response.items_created || 0,
|
|
156
|
+
updated: response.items_updated || 0,
|
|
157
|
+
errors: response.errors?.length || 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setStats(newStats)
|
|
161
|
+
setIngestedItemIds(response.item_ids || [])
|
|
162
|
+
addLog(`Ingestion complete: ${response.items_created || 0} created, ${response.items_updated || 0} updated`)
|
|
163
|
+
addLog(`Captured ${(response.item_ids || []).length} item IDs for embedding generation`)
|
|
164
|
+
|
|
165
|
+
if (response.errors && response.errors.length > 0) {
|
|
166
|
+
addLog(`Errors: ${response.errors.join(', ')}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setCurrentStep('ingestion_complete')
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
|
172
|
+
setError(errorMessage)
|
|
173
|
+
addLog(`Error: ${errorMessage}`)
|
|
174
|
+
} finally {
|
|
175
|
+
setIsLoading(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const handleGenerateEmbeddings = async () => {
|
|
180
|
+
setIsLoading(true)
|
|
181
|
+
setError(null)
|
|
182
|
+
setLogs([])
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
addLog('Generating embeddings for ingested items...')
|
|
186
|
+
const itemIds = ingestedItemIds
|
|
187
|
+
if (itemIds.length === 0) {
|
|
188
|
+
throw new Error('No item IDs available — run ingestion first')
|
|
189
|
+
}
|
|
190
|
+
addLog(`Generating embeddings for ${itemIds.length} items...`)
|
|
191
|
+
const response = await generateEmbeddings(itemIds)
|
|
192
|
+
|
|
193
|
+
addLog(`Embeddings complete: ${(response as any).embeddings_created || response.embeddings_generated || 0} generated`)
|
|
194
|
+
setCurrentStep('embeddings_complete')
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
|
197
|
+
setError(errorMessage)
|
|
198
|
+
addLog(`Error: ${errorMessage}`)
|
|
199
|
+
} finally {
|
|
200
|
+
setIsLoading(false)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
|
206
|
+
<Card>
|
|
207
|
+
<CardHeader>
|
|
208
|
+
<CardTitle>KB Code Chunk Ingestion</CardTitle>
|
|
209
|
+
<CardDescription>
|
|
210
|
+
Step-by-step ingestion of parsed code chunks from v2-core functions into the Knowledge Base system.
|
|
211
|
+
</CardDescription>
|
|
212
|
+
</CardHeader>
|
|
213
|
+
<CardContent className="space-y-6">
|
|
214
|
+
{/* Step 1: Load Chunks */}
|
|
215
|
+
<Card>
|
|
216
|
+
<CardHeader>
|
|
217
|
+
<CardTitle className="text-lg">Step 1: Load Chunk Data</CardTitle>
|
|
218
|
+
<CardDescription>
|
|
219
|
+
Load parsed code chunks from the chunks.json file
|
|
220
|
+
</CardDescription>
|
|
221
|
+
</CardHeader>
|
|
222
|
+
<CardContent className="space-y-4">
|
|
223
|
+
<Button
|
|
224
|
+
onClick={handleLoadChunks}
|
|
225
|
+
disabled={isLoading}
|
|
226
|
+
size="lg"
|
|
227
|
+
>
|
|
228
|
+
{isLoading ? 'Loading...' : currentStep === 'idle' ? 'Load Chunk Data' : 'Reload Chunk Data'}
|
|
229
|
+
</Button>
|
|
230
|
+
|
|
231
|
+
{currentStep !== 'idle' && (
|
|
232
|
+
<div className="space-y-2">
|
|
233
|
+
<Badge variant="outline">
|
|
234
|
+
Chunks Loaded: {chunks.length}
|
|
235
|
+
</Badge>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</CardContent>
|
|
239
|
+
</Card>
|
|
240
|
+
|
|
241
|
+
{/* Step 2: Display Chunks */}
|
|
242
|
+
{currentStep !== 'idle' && chunks.length > 0 && (
|
|
243
|
+
<Card>
|
|
244
|
+
<CardHeader>
|
|
245
|
+
<CardTitle className="text-lg">Chunk Data Preview</CardTitle>
|
|
246
|
+
<CardDescription>
|
|
247
|
+
{chunks.length} chunks loaded from file
|
|
248
|
+
</CardDescription>
|
|
249
|
+
</CardHeader>
|
|
250
|
+
<CardContent>
|
|
251
|
+
<ScrollArea className="h-64 w-full border rounded-md p-4">
|
|
252
|
+
<div className="space-y-2">
|
|
253
|
+
{chunks.slice(0, 10).map((chunk, index) => (
|
|
254
|
+
<div key={index} className="text-sm border-b pb-2">
|
|
255
|
+
<div className="font-medium">{chunk.identifier}</div>
|
|
256
|
+
<div className="text-gray-600">{chunk.macro}: {chunk.micro}</div>
|
|
257
|
+
<div className="text-xs text-gray-500">
|
|
258
|
+
{chunk.chunk_id} • {chunk.version}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
))}
|
|
262
|
+
{chunks.length > 10 && (
|
|
263
|
+
<div className="text-sm text-gray-500 italic">
|
|
264
|
+
... and {chunks.length - 10} more chunks
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</ScrollArea>
|
|
269
|
+
</CardContent>
|
|
270
|
+
</Card>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{/* Step 3: Ingest Chunks */}
|
|
274
|
+
{currentStep === 'chunks_loaded' && (
|
|
275
|
+
<Card>
|
|
276
|
+
<CardHeader>
|
|
277
|
+
<CardTitle className="text-lg">Step 2: Ingest Chunks</CardTitle>
|
|
278
|
+
<CardDescription>
|
|
279
|
+
Create KB articles from the loaded chunks
|
|
280
|
+
</CardDescription>
|
|
281
|
+
</CardHeader>
|
|
282
|
+
<CardContent className="space-y-4">
|
|
283
|
+
<div className="flex items-center gap-4">
|
|
284
|
+
<div className="flex items-center gap-2">
|
|
285
|
+
<label htmlFor="ingest-from" className="text-sm font-medium">Ingest records</label>
|
|
286
|
+
<input
|
|
287
|
+
id="ingest-from"
|
|
288
|
+
type="number"
|
|
289
|
+
min="1"
|
|
290
|
+
max={chunks.length}
|
|
291
|
+
value={ingestFrom}
|
|
292
|
+
onChange={(e) => setIngestFrom(e.target.value)}
|
|
293
|
+
placeholder="1"
|
|
294
|
+
className="w-20 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
295
|
+
/>
|
|
296
|
+
<span className="text-sm font-medium">through</span>
|
|
297
|
+
<input
|
|
298
|
+
id="ingest-to"
|
|
299
|
+
type="number"
|
|
300
|
+
min="1"
|
|
301
|
+
max={chunks.length}
|
|
302
|
+
value={ingestTo}
|
|
303
|
+
onChange={(e) => setIngestTo(e.target.value)}
|
|
304
|
+
placeholder={chunks.length.toString()}
|
|
305
|
+
className="w-20 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div className="text-sm text-gray-600">
|
|
310
|
+
({(() => {
|
|
311
|
+
const from = parseInt(ingestFrom) || 1
|
|
312
|
+
const to = parseInt(ingestTo) || chunks.length
|
|
313
|
+
const count = from > 0 && to > 0 && from <= to && to <= chunks.length
|
|
314
|
+
? to - from + 1
|
|
315
|
+
: 0
|
|
316
|
+
return count > 0 ? `${count} records` : 'Invalid range'
|
|
317
|
+
})()})
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<Button
|
|
322
|
+
onClick={handleIngestChunks}
|
|
323
|
+
disabled={isLoading || !ingestFrom || !ingestTo}
|
|
324
|
+
size="lg"
|
|
325
|
+
variant="default"
|
|
326
|
+
>
|
|
327
|
+
{isLoading ? 'Ingesting...' : `Ingest ${(() => {
|
|
328
|
+
const from = parseInt(ingestFrom) || 1
|
|
329
|
+
const to = parseInt(ingestTo) || chunks.length
|
|
330
|
+
const count = from > 0 && to > 0 && from <= to && to <= chunks.length
|
|
331
|
+
? to - from + 1
|
|
332
|
+
: 0
|
|
333
|
+
return count
|
|
334
|
+
})()} Records`}
|
|
335
|
+
</Button>
|
|
336
|
+
|
|
337
|
+
{stats && (
|
|
338
|
+
<div className="flex gap-2">
|
|
339
|
+
<Badge variant="outline">
|
|
340
|
+
Total: {stats.total}
|
|
341
|
+
</Badge>
|
|
342
|
+
<Badge variant="outline">
|
|
343
|
+
Created: {stats.created}
|
|
344
|
+
</Badge>
|
|
345
|
+
<Badge variant="outline">
|
|
346
|
+
Updated: {stats.updated}
|
|
347
|
+
</Badge>
|
|
348
|
+
{stats.errors > 0 && (
|
|
349
|
+
<Badge variant="destructive">
|
|
350
|
+
Errors: {stats.errors}
|
|
351
|
+
</Badge>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</CardContent>
|
|
356
|
+
</Card>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{/* Step 4: Generate Embeddings */}
|
|
360
|
+
{currentStep === 'ingestion_complete' && stats && stats.created > 0 && (
|
|
361
|
+
<Card>
|
|
362
|
+
<CardHeader>
|
|
363
|
+
<CardTitle className="text-lg">Step 3: Generate Embeddings</CardTitle>
|
|
364
|
+
<CardDescription>
|
|
365
|
+
Generate vector embeddings for the {stats.created} created KB articles
|
|
366
|
+
</CardDescription>
|
|
367
|
+
</CardHeader>
|
|
368
|
+
<CardContent className="space-y-4">
|
|
369
|
+
<Button
|
|
370
|
+
onClick={handleGenerateEmbeddings}
|
|
371
|
+
disabled={isLoading}
|
|
372
|
+
size="lg"
|
|
373
|
+
variant="default"
|
|
374
|
+
>
|
|
375
|
+
{isLoading ? 'Generating...' : 'Generate Embeddings'}
|
|
376
|
+
</Button>
|
|
377
|
+
</CardContent>
|
|
378
|
+
</Card>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* Error Display */}
|
|
382
|
+
{error && (
|
|
383
|
+
<Alert variant="destructive">
|
|
384
|
+
<AlertDescription>{error}</AlertDescription>
|
|
385
|
+
</Alert>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{/* Activity Log */}
|
|
389
|
+
{logs.length > 0 && (
|
|
390
|
+
<Card>
|
|
391
|
+
<CardHeader>
|
|
392
|
+
<CardTitle className="text-lg">Activity Log</CardTitle>
|
|
393
|
+
</CardHeader>
|
|
394
|
+
<CardContent>
|
|
395
|
+
<ScrollArea className="h-64 w-full">
|
|
396
|
+
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
|
397
|
+
<pre className="text-xs font-mono whitespace-pre-wrap">
|
|
398
|
+
{logs.join('\n')}
|
|
399
|
+
</pre>
|
|
400
|
+
</div>
|
|
401
|
+
</ScrollArea>
|
|
402
|
+
</CardContent>
|
|
403
|
+
</Card>
|
|
404
|
+
)}
|
|
405
|
+
</CardContent>
|
|
406
|
+
</Card>
|
|
407
|
+
</div>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { apiFetch } from '@core/lib/api'
|
|
4
|
+
import { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
5
|
+
import { Button } from '@core/components/ui/button'
|
|
6
|
+
import { Input } from '@core/components/ui/input'
|
|
7
|
+
import { Badge } from '@core/components/ui/badge'
|
|
8
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
9
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
10
|
+
import { Plus, BookOpen, Edit } from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
interface Article {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
status?: string
|
|
17
|
+
data?: Record<string, any>
|
|
18
|
+
created_at: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUS_GROUPS: { key: string; label: string }[] = [
|
|
22
|
+
{ key: 'draft', label: 'Draft' },
|
|
23
|
+
{ key: 'review', label: 'Under Review' },
|
|
24
|
+
{ key: 'published', label: 'Published' },
|
|
25
|
+
{ key: 'deprecated', label: 'Deprecated' },
|
|
26
|
+
{ key: 'archived', label: 'Archived' },
|
|
27
|
+
{ key: 'restricted', label: 'Restricted' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const KB_TYPE_LABELS: Record<string, string> = {
|
|
31
|
+
article: 'Article',
|
|
32
|
+
care_guide: 'Care Guide',
|
|
33
|
+
process_guide: 'Process Guide',
|
|
34
|
+
code_chunk: 'Code',
|
|
35
|
+
api_reference: 'API Ref',
|
|
36
|
+
troubleshooting: 'Troubleshooting',
|
|
37
|
+
faq: 'FAQ',
|
|
38
|
+
policy: 'Policy',
|
|
39
|
+
tutorial: 'Tutorial',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PRIORITY_COLORS: Record<string, string> = {
|
|
43
|
+
low: 'secondary',
|
|
44
|
+
medium: 'secondary',
|
|
45
|
+
high: 'default',
|
|
46
|
+
critical: 'destructive',
|
|
47
|
+
emergency: 'destructive',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function KBPage() {
|
|
51
|
+
const navigate = useNavigate()
|
|
52
|
+
const [articles, setArticles] = useState<Article[]>([])
|
|
53
|
+
const [searchResults, setSearchResults] = useState<Article[] | null>(null)
|
|
54
|
+
const [loading, setLoading] = useState(true)
|
|
55
|
+
const [searching, setSearching] = useState(false)
|
|
56
|
+
const [search, setSearch] = useState('')
|
|
57
|
+
const [selected, setSelected] = useState<Article | null>(null)
|
|
58
|
+
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
59
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
|
|
60
|
+
|
|
61
|
+
// Load all articles on mount
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=kb_article&limit=500')
|
|
64
|
+
.then(r => r.json())
|
|
65
|
+
.then(j => setArticles(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []))
|
|
66
|
+
.catch(() => setArticles([]))
|
|
67
|
+
.finally(() => setLoading(false))
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// Debounced vector search
|
|
71
|
+
const runSearch = useCallback(async (q: string) => {
|
|
72
|
+
if (q.trim().length < 2) { setSearchResults(null); return }
|
|
73
|
+
setSearching(true)
|
|
74
|
+
try {
|
|
75
|
+
const res = await apiFetch('/api/custom_kb-embeddings?action=search', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ query: q.trim(), limit: 15 }),
|
|
79
|
+
})
|
|
80
|
+
const json = await res.json()
|
|
81
|
+
const results = json.data || json || []
|
|
82
|
+
setSearchResults(Array.isArray(results) ? results : [])
|
|
83
|
+
} catch {
|
|
84
|
+
setSearchResults(null) // Fall back to client-side filter
|
|
85
|
+
} finally {
|
|
86
|
+
setSearching(false)
|
|
87
|
+
}
|
|
88
|
+
}, [])
|
|
89
|
+
|
|
90
|
+
const handleSearch = (val: string) => {
|
|
91
|
+
setSearch(val)
|
|
92
|
+
clearTimeout(debounceRef.current)
|
|
93
|
+
if (!val || val.trim().length < 2) {
|
|
94
|
+
setSearchResults(null)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
debounceRef.current = setTimeout(() => runSearch(val), 400)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Use vector results when available, otherwise client-side filter
|
|
101
|
+
const displayArticles = searchResults ?? articles
|
|
102
|
+
const filtered = displayArticles.filter(a => {
|
|
103
|
+
const matchesStatus = statusFilter === 'all' || (a.status || 'draft') === statusFilter
|
|
104
|
+
return matchesStatus
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const grouped = STATUS_GROUPS.map(sg => ({
|
|
108
|
+
...sg,
|
|
109
|
+
items: filtered.filter(a => (a.status || 'draft') === sg.key),
|
|
110
|
+
})).filter(g => g.items.length > 0)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="flex h-full min-h-0">
|
|
114
|
+
{/* Left panel */}
|
|
115
|
+
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
116
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
|
117
|
+
<Input
|
|
118
|
+
placeholder="Search articles…"
|
|
119
|
+
value={search}
|
|
120
|
+
onChange={e => handleSearch(e.target.value)}
|
|
121
|
+
className="flex-1 h-8"
|
|
122
|
+
/>
|
|
123
|
+
<Button size="sm" className="gap-1 shrink-0" onClick={() => navigate('/cortex/kb/new')}>
|
|
124
|
+
<Plus size={14} /> New
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Status filter */}
|
|
129
|
+
<div className="flex flex-wrap gap-1 px-3 py-2 border-b border-border shrink-0">
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => setStatusFilter('all')}
|
|
132
|
+
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${statusFilter === 'all' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:text-foreground'}`}
|
|
133
|
+
>
|
|
134
|
+
All
|
|
135
|
+
</button>
|
|
136
|
+
{STATUS_GROUPS.map(sg => (
|
|
137
|
+
<button
|
|
138
|
+
key={sg.key}
|
|
139
|
+
onClick={() => setStatusFilter(sg.key)}
|
|
140
|
+
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${statusFilter === sg.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:text-foreground'}`}
|
|
141
|
+
>
|
|
142
|
+
{sg.label}
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<ScrollArea className="flex-1">
|
|
148
|
+
{loading ? (
|
|
149
|
+
<div className="p-4 space-y-2">
|
|
150
|
+
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}
|
|
151
|
+
</div>
|
|
152
|
+
) : filtered.length === 0 ? (
|
|
153
|
+
<div className="p-8 text-center text-muted-foreground">
|
|
154
|
+
<BookOpen className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
155
|
+
<p className="text-sm">{search ? 'No articles match.' : 'No articles yet.'}</p>
|
|
156
|
+
<Button size="sm" variant="outline" className="mt-3 gap-1" onClick={() => navigate('/cortex/kb/new')}>
|
|
157
|
+
<Plus size={13} /> Create article
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
) : (
|
|
161
|
+
<div>
|
|
162
|
+
{grouped.map(group => (
|
|
163
|
+
<div key={group.key}>
|
|
164
|
+
<p className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
165
|
+
{group.label} ({group.items.length})
|
|
166
|
+
</p>
|
|
167
|
+
{group.items.map(a => (
|
|
168
|
+
<button
|
|
169
|
+
key={a.id}
|
|
170
|
+
onClick={() => setSelected(a)}
|
|
171
|
+
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 transition-colors ${selected?.id === a.id ? 'bg-accent border-l-2 border-l-primary' : ''}`}
|
|
172
|
+
>
|
|
173
|
+
<p className="text-sm font-medium truncate">{a.title}</p>
|
|
174
|
+
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
|
175
|
+
{a.data?.kb_type && (
|
|
176
|
+
<span className="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
|
|
177
|
+
{KB_TYPE_LABELS[a.data.kb_type] || a.data.kb_type}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
{a.data?.priority && a.data.priority !== 'medium' && (
|
|
181
|
+
<span className={`text-xs px-1.5 py-0.5 rounded ${a.data.priority === 'high' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : a.data.priority === 'critical' || a.data.priority === 'emergency' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : 'bg-muted text-muted-foreground'}`}>
|
|
182
|
+
{a.data.priority}
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</ScrollArea>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Right preview panel */}
|
|
196
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
197
|
+
{!selected ? (
|
|
198
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
199
|
+
<BookOpen size={32} className="opacity-30" />
|
|
200
|
+
<p className="text-sm">Select an article to preview</p>
|
|
201
|
+
<Button size="sm" variant="outline" onClick={() => navigate('/cortex/kb/new')} className="gap-1">
|
|
202
|
+
<Plus size={13} /> New article
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
) : (
|
|
206
|
+
<>
|
|
207
|
+
<div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between">
|
|
208
|
+
<div>
|
|
209
|
+
<h1 className="text-base font-semibold">{selected.title}</h1>
|
|
210
|
+
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
211
|
+
<Badge variant={selected.status === 'published' ? 'default' : 'outline'}>
|
|
212
|
+
{selected.status || 'draft'}
|
|
213
|
+
</Badge>
|
|
214
|
+
{selected.data?.kb_type && (
|
|
215
|
+
<Badge variant="secondary" className="text-xs">
|
|
216
|
+
{KB_TYPE_LABELS[selected.data.kb_type] || selected.data.kb_type}
|
|
217
|
+
</Badge>
|
|
218
|
+
)}
|
|
219
|
+
{selected.data?.priority && (
|
|
220
|
+
<Badge
|
|
221
|
+
variant={(PRIORITY_COLORS[selected.data.priority] as any) || 'secondary'}
|
|
222
|
+
className="text-xs"
|
|
223
|
+
>
|
|
224
|
+
{selected.data.priority}
|
|
225
|
+
</Badge>
|
|
226
|
+
)}
|
|
227
|
+
{selected.data?.category && (
|
|
228
|
+
<span className="text-xs text-muted-foreground">{selected.data.category}</span>
|
|
229
|
+
)}
|
|
230
|
+
<span className="text-xs text-muted-foreground">
|
|
231
|
+
{new Date(selected.created_at).toLocaleDateString()}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<Button
|
|
236
|
+
size="sm"
|
|
237
|
+
variant="outline"
|
|
238
|
+
className="gap-1 shrink-0"
|
|
239
|
+
onClick={() => navigate(`/cortex/kb/${selected.id}/edit`)}
|
|
240
|
+
>
|
|
241
|
+
<Edit size={13} /> Edit
|
|
242
|
+
</Button>
|
|
243
|
+
</div>
|
|
244
|
+
<ScrollArea className="flex-1">
|
|
245
|
+
<div className="px-6 py-5 max-w-3xl">
|
|
246
|
+
{selected.description ? (
|
|
247
|
+
<RichTextEditor value={selected.description} readonly />
|
|
248
|
+
) : (
|
|
249
|
+
<p className="text-sm text-muted-foreground italic">No content yet. Click Edit to add content.</p>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</ScrollArea>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
}
|