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.
Files changed (40) hide show
  1. package/components/CortexSidebar.tsx +130 -0
  2. package/functions/custom_anonymous-sessions.ts +356 -0
  3. package/functions/custom_case_analysis.ts +507 -0
  4. package/functions/custom_community-escalation.ts +234 -0
  5. package/functions/custom_cortex-chunks.ts +52 -0
  6. package/functions/custom_cortex-handler.ts +35 -0
  7. package/functions/custom_funnel-scoring.ts +256 -0
  8. package/functions/custom_funnel-signal.ts +678 -0
  9. package/functions/custom_funnel-timers.ts +449 -0
  10. package/functions/custom_kb-chunker-test.ts +364 -0
  11. package/functions/custom_kb-chunker.ts +576 -0
  12. package/functions/custom_kb-embeddings.ts +481 -0
  13. package/functions/custom_kb-ingestion.ts +448 -0
  14. package/functions/custom_support-triage.ts +649 -0
  15. package/functions/custom_tag_management.ts +314 -0
  16. package/index.tsx +103 -0
  17. package/manifest.json +82 -0
  18. package/package.json +29 -0
  19. package/pages/CortexDashboard.tsx +97 -0
  20. package/pages/community/CommunityPage.tsx +159 -0
  21. package/pages/courses/CoursesPage.tsx +231 -0
  22. package/pages/crm/AccountDetailPage.tsx +393 -0
  23. package/pages/crm/AccountsPage.tsx +164 -0
  24. package/pages/crm/ActivityPage.tsx +82 -0
  25. package/pages/crm/ContactDetailPage.tsx +184 -0
  26. package/pages/crm/ContactsPage.tsx +87 -0
  27. package/pages/crm/DealDetailPage.tsx +191 -0
  28. package/pages/crm/DealsPage.tsx +169 -0
  29. package/pages/crm/HealthPage.tsx +109 -0
  30. package/pages/intelligence/IntelligencePage.tsx +314 -0
  31. package/pages/kb/KBEditorPage.tsx +328 -0
  32. package/pages/kb/KBIngestionPage.tsx +409 -0
  33. package/pages/kb/KBPage.tsx +258 -0
  34. package/pages/support/RedactionReview.tsx +562 -0
  35. package/pages/support/SupportPage.tsx +395 -0
  36. package/pages/support/TicketDetailPage.tsx +919 -0
  37. package/seed/accounts.json +9 -0
  38. package/seed/link-types.json +44 -0
  39. package/seed/triggers.json +80 -0
  40. 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
+ }