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,562 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { resolveTypeId } from '../../../lib/resolveTypeId'
3
+ import { useParams, useNavigate } from 'react-router-dom'
4
+ import { apiFetch } from '@core/lib/api'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Badge } from '@core/components/ui/badge'
7
+ import { ScrollArea } from '@core/components/ui/scroll-area'
8
+ import { Textarea } from '@core/components/ui/textarea'
9
+ import { Input } from '@core/components/ui/input'
10
+ import { Label } from '@core/components/ui/label'
11
+ import { Checkbox } from '@core/components/ui/checkbox'
12
+ import { ArrowLeft, AlertCircle, EyeOff, Eye, CheckCircle, BookOpen, AlertTriangle } from 'lucide-react'
13
+
14
+ interface RedactionSuggestion {
15
+ id: string
16
+ start_index: number
17
+ end_index: number
18
+ original_text: string
19
+ redacted_text: string
20
+ sensitivity_level: 'high' | 'medium' | 'low'
21
+ reasoning: string
22
+ category: 'pii' | 'confidential' | 'account_specific' | 'internal_reference'
23
+ }
24
+
25
+ interface RedactionAnalysis {
26
+ original_content: string
27
+ redacted_content: string
28
+ suggestions: RedactionSuggestion[]
29
+ confidence_score: number
30
+ processing_metadata: {
31
+ model_used: string
32
+ temperature: number
33
+ tokens_consumed: number
34
+ }
35
+ }
36
+
37
+ interface Ticket {
38
+ id: string
39
+ title: string
40
+ description?: string
41
+ data?: {
42
+ ai_metadata?: {
43
+ problem_statement?: string
44
+ solution_path?: string
45
+ }
46
+ postmortem?: {
47
+ kb_draft_id?: string
48
+ }
49
+ }
50
+ }
51
+
52
+ const SENSITIVITY_COLORS = {
53
+ high: 'bg-red-100 text-red-700 border-red-200',
54
+ medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
55
+ low: 'bg-blue-100 text-blue-700 border-blue-200',
56
+ }
57
+
58
+ const SENSITIVITY_ICONS = {
59
+ high: AlertCircle,
60
+ medium: AlertTriangle,
61
+ low: EyeOff,
62
+ }
63
+
64
+ function parseContentWithRedactions(content: string, suggestions: RedactionSuggestion[]): (string | RedactionSuggestion)[] {
65
+ if (!suggestions.length) return [content]
66
+
67
+ const parts: (string | RedactionSuggestion)[] = []
68
+ let lastIndex = 0
69
+
70
+ // Sort by start index
71
+ const sorted = [...suggestions].sort((a, b) => a.start_index - b.start_index)
72
+
73
+ for (const suggestion of sorted) {
74
+ if (suggestion.start_index > lastIndex) {
75
+ parts.push(content.slice(lastIndex, suggestion.start_index))
76
+ }
77
+ parts.push(suggestion)
78
+ lastIndex = suggestion.end_index
79
+ }
80
+
81
+ if (lastIndex < content.length) {
82
+ parts.push(content.slice(lastIndex))
83
+ }
84
+
85
+ return parts
86
+ }
87
+
88
+ export default function RedactionReview() {
89
+ const { id } = useParams<{ id: string }>()
90
+ const navigate = useNavigate()
91
+ const [ticket, setTicket] = useState<Ticket | null>(null)
92
+ const [analysis, setAnalysis] = useState<RedactionAnalysis | null>(null)
93
+ const [loading, setLoading] = useState(true)
94
+ const [processing, setProcessing] = useState(false)
95
+ const [acceptedSuggestions, setAcceptedSuggestions] = useState<Set<string>>(new Set())
96
+ const [rejectedSuggestions, setRejectedSuggestions] = useState<Set<string>>(new Set())
97
+ const [customRedactions, setCustomRedactions] = useState<RedactionSuggestion[]>([])
98
+ const [newRedaction, setNewRedaction] = useState({ text: '', reason: '', level: 'medium' as const })
99
+ const [finalContent, setFinalContent] = useState('')
100
+ const [articleTitle, setArticleTitle] = useState('')
101
+ const [viewMode, setViewMode] = useState<'review' | 'final'>('review')
102
+
103
+ // Load ticket and trigger redaction analysis
104
+ useEffect(() => {
105
+ if (!id) return
106
+
107
+ const loadAndAnalyze = async () => {
108
+ setLoading(true)
109
+ try {
110
+ // Load ticket
111
+ const tRes = await apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json())
112
+ const ticketData = tRes?.data ?? tRes ?? null
113
+ setTicket(ticketData)
114
+
115
+ // Generate article title from ticket
116
+ if (ticketData?.title) {
117
+ setArticleTitle(`KB: ${ticketData.title}`)
118
+ }
119
+
120
+ // Trigger redaction analysis via AI agent
121
+ const analysisRes = await apiFetch('/api/ai-agents', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({
125
+ action: 'run_redaction_analysis',
126
+ ticket_id: id,
127
+ content: buildContentForAnalysis(ticketData),
128
+ }),
129
+ }).then(r => r.json())
130
+
131
+ if (analysisRes?.analysis) {
132
+ setAnalysis(analysisRes.analysis)
133
+ // Initially accept all high-sensitivity suggestions
134
+ const autoAccept = new Set(
135
+ analysisRes.analysis.suggestions
136
+ .filter((s: RedactionSuggestion) => s.sensitivity_level === 'high')
137
+ .map((s: RedactionSuggestion) => s.id)
138
+ )
139
+ setAcceptedSuggestions(autoAccept)
140
+ updateFinalContent(analysisRes.analysis, autoAccept, new Set(), [])
141
+ }
142
+ } catch (err) {
143
+ console.error('Failed to analyze:', err)
144
+ } finally {
145
+ setLoading(false)
146
+ }
147
+ }
148
+
149
+ loadAndAnalyze()
150
+ }, [id])
151
+
152
+ // Build content from ticket for analysis
153
+ function buildContentForAnalysis(ticket: Ticket): string {
154
+ const parts: string[] = []
155
+ if (ticket.title) parts.push(`Title: ${ticket.title}`)
156
+ if (ticket.description) parts.push(`Description: ${ticket.description}`)
157
+ if (ticket.data?.ai_metadata?.problem_statement) {
158
+ parts.push(`Problem: ${ticket.data.ai_metadata.problem_statement}`)
159
+ }
160
+ if (ticket.data?.ai_metadata?.solution_path) {
161
+ parts.push(`Solution: ${ticket.data.ai_metadata.solution_path}`)
162
+ }
163
+ return parts.join('\n\n')
164
+ }
165
+
166
+ // Update final content based on accepted/rejected suggestions
167
+ const updateFinalContent = useCallback((
168
+ analysis: RedactionAnalysis | null,
169
+ accepted: Set<string>,
170
+ rejected: Set<string>,
171
+ custom: RedactionSuggestion[]
172
+ ) => {
173
+ if (!analysis) return
174
+
175
+ let content = analysis.original_content
176
+
177
+ // Get all accepted redactions (both AI and custom), sorted by position (reverse)
178
+ const allAccepted = [
179
+ ...analysis.suggestions.filter(s => accepted.has(s.id) && !rejected.has(s.id)),
180
+ ...custom.filter(c => !rejected.has(c.id)),
181
+ ].sort((a, b) => b.start_index - a.start_index)
182
+
183
+ // Apply redactions from end to start to preserve indices
184
+ for (const redaction of allAccepted) {
185
+ content = content.slice(0, redaction.start_index) + redaction.redacted_text + content.slice(redaction.end_index)
186
+ }
187
+
188
+ setFinalContent(content)
189
+ }, [])
190
+
191
+ // Toggle suggestion acceptance
192
+ const toggleSuggestion = (suggestionId: string) => {
193
+ const newAccepted = new Set(acceptedSuggestions)
194
+ const newRejected = new Set(rejectedSuggestions)
195
+
196
+ if (newAccepted.has(suggestionId)) {
197
+ newAccepted.delete(suggestionId)
198
+ newRejected.add(suggestionId)
199
+ } else {
200
+ newAccepted.add(suggestionId)
201
+ newRejected.delete(suggestionId)
202
+ }
203
+
204
+ setAcceptedSuggestions(newAccepted)
205
+ setRejectedSuggestions(newRejected)
206
+ updateFinalContent(analysis, newAccepted, newRejected, customRedactions)
207
+ }
208
+
209
+ // Add custom redaction
210
+ const addCustomRedaction = () => {
211
+ if (!newRedaction.text || !analysis) return
212
+
213
+ const startIndex = analysis.original_content.indexOf(newRedaction.text)
214
+ if (startIndex === -1) return
215
+
216
+ const customRedaction: RedactionSuggestion = {
217
+ id: `custom-${Date.now()}`,
218
+ start_index: startIndex,
219
+ end_index: startIndex + newRedaction.text.length,
220
+ original_text: newRedaction.text,
221
+ redacted_text: '[REDACTED]',
222
+ sensitivity_level: newRedaction.level,
223
+ reasoning: newRedaction.reason || 'Manually added',
224
+ category: 'account_specific',
225
+ }
226
+
227
+ const newCustom = [...customRedactions, customRedaction]
228
+ setCustomRedactions(newCustom)
229
+ setAcceptedSuggestions(new Set([...acceptedSuggestions, customRedaction.id]))
230
+ setNewRedaction({ text: '', reason: '', level: 'medium' })
231
+ updateFinalContent(analysis, acceptedSuggestions, rejectedSuggestions, newCustom)
232
+ }
233
+
234
+ // Publish KB article
235
+ const publishKB = async () => {
236
+ if (!id || !finalContent || !articleTitle) return
237
+
238
+ setProcessing(true)
239
+ try {
240
+ const kbArticleTypeId = await resolveTypeId('kb_article')
241
+ // Create KB article
242
+ const kbRes = await apiFetch('/api/admin-data?action=create&entity=items', {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify({
246
+ type_id: kbArticleTypeId,
247
+ title: articleTitle,
248
+ status: 'published',
249
+ description: finalContent,
250
+ data: {
251
+ kb_type: 'article',
252
+ priority: 'medium',
253
+ security_level: 'internal',
254
+ source_info: {
255
+ source_type: 'redaction_review',
256
+ source_ticket_id: id,
257
+ redaction_review: {
258
+ ai_confidence: analysis?.confidence_score,
259
+ accepted_redactions: acceptedSuggestions.size,
260
+ rejected_redactions: rejectedSuggestions.size,
261
+ custom_redactions: customRedactions.length,
262
+ },
263
+ },
264
+ },
265
+ }),
266
+ }).then(r => r.json())
267
+
268
+ if (kbRes?.id || kbRes?.data?.id) {
269
+ const kbId = kbRes.id || kbRes.data.id
270
+
271
+ // Update ticket with KB reference
272
+ await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify({
276
+ data: {
277
+ ...ticket?.data,
278
+ postmortem: {
279
+ ...ticket?.data?.postmortem,
280
+ kb_generated: true,
281
+ kb_draft_id: kbId,
282
+ },
283
+ },
284
+ }),
285
+ })
286
+
287
+ navigate(`/cortex/kb/${kbId}`)
288
+ }
289
+ } finally {
290
+ setProcessing(false)
291
+ }
292
+ }
293
+
294
+ if (loading) {
295
+ return (
296
+ <div className="p-6 space-y-4">
297
+ <div className="h-8 w-64 bg-muted rounded animate-pulse" />
298
+ <div className="h-64 bg-muted rounded animate-pulse" />
299
+ </div>
300
+ )
301
+ }
302
+
303
+ if (!analysis) {
304
+ return (
305
+ <div className="p-6">
306
+ <div className="flex items-center gap-2 text-amber-600 mb-4">
307
+ <AlertTriangle className="h-5 w-5" />
308
+ <p>Failed to analyze content for redaction. Please try again.</p>
309
+ </div>
310
+ <Button onClick={() => navigate(`/cortex/support/${id}`)}>Back to Ticket</Button>
311
+ </div>
312
+ )
313
+ }
314
+
315
+ return (
316
+ <div className="flex flex-col h-full">
317
+ {/* Header */}
318
+ <div className="px-6 py-4 border-b border-border shrink-0">
319
+ <div className="flex items-center justify-between">
320
+ <div className="flex items-center gap-3">
321
+ <Button variant="ghost" size="sm" onClick={() => navigate(`/cortex/support/${id}`)}>
322
+ <ArrowLeft className="h-4 w-4" />
323
+ </Button>
324
+ <div>
325
+ <h1 className="text-lg font-semibold flex items-center gap-2">
326
+ <BookOpen className="h-5 w-5 text-purple-600" />
327
+ KB Article Redaction Review
328
+ </h1>
329
+ <p className="text-xs text-muted-foreground">Review AI suggestions before publishing</p>
330
+ </div>
331
+ </div>
332
+ <div className="flex items-center gap-2">
333
+ <Button
334
+ variant={viewMode === 'review' ? 'default' : 'outline'}
335
+ size="sm"
336
+ onClick={() => setViewMode('review')}
337
+ >
338
+ <Eye className="h-4 w-4 mr-1" />
339
+ Review
340
+ </Button>
341
+ <Button
342
+ variant={viewMode === 'final' ? 'default' : 'outline'}
343
+ size="sm"
344
+ onClick={() => setViewMode('final')}
345
+ >
346
+ <CheckCircle className="h-4 w-4 mr-1" />
347
+ Final
348
+ </Button>
349
+ </div>
350
+ </div>
351
+ </div>
352
+
353
+ {/* Content */}
354
+ <div className="flex flex-1 min-h-0">
355
+ {viewMode === 'review' ? (
356
+ <>
357
+ {/* Left: Content with Redactions */}
358
+ <div className="flex-1 min-w-0 border-r border-border">
359
+ <ScrollArea className="h-full p-6">
360
+ <div className="space-y-6">
361
+ {/* Title Input */}
362
+ <div className="space-y-2">
363
+ <Label>Article Title</Label>
364
+ <Input
365
+ value={articleTitle}
366
+ onChange={e => setArticleTitle(e.target.value)}
367
+ placeholder="KB Article Title"
368
+ />
369
+ </div>
370
+
371
+ {/* Content Preview */}
372
+ <div className="space-y-2">
373
+ <Label>Content Preview</Label>
374
+ <div className="border rounded-lg p-4 bg-white whitespace-pre-wrap text-sm leading-relaxed">
375
+ {parseContentWithRedactions(
376
+ analysis.original_content,
377
+ analysis.suggestions.filter(s => acceptedSuggestions.has(s.id))
378
+ ).map((part, i) => {
379
+ if (typeof part === 'string') {
380
+ return <span key={i}>{part}</span>
381
+ }
382
+ return (
383
+ <mark
384
+ key={i}
385
+ className={`px-1 rounded ${
386
+ part.sensitivity_level === 'high'
387
+ ? 'bg-red-200'
388
+ : part.sensitivity_level === 'medium'
389
+ ? 'bg-yellow-200'
390
+ : 'bg-blue-200'
391
+ }`}
392
+ title={part.reasoning}
393
+ >
394
+ {part.redacted_text}
395
+ </mark>
396
+ )
397
+ })}
398
+ </div>
399
+ </div>
400
+
401
+ {/* Custom Redaction */}
402
+ <div className="space-y-3 pt-4 border-t">
403
+ <Label>Add Custom Redaction</Label>
404
+ <div className="flex gap-2">
405
+ <Input
406
+ value={newRedaction.text}
407
+ onChange={e => setNewRedaction({ ...newRedaction, text: e.target.value })}
408
+ placeholder="Text to redact"
409
+ className="flex-1"
410
+ />
411
+ <select
412
+ value={newRedaction.level}
413
+ onChange={e => setNewRedaction({ ...newRedaction, level: e.target.value as 'high' | 'medium' | 'low' })}
414
+ className="border rounded px-2 text-sm"
415
+ >
416
+ <option value="high">High</option>
417
+ <option value="medium">Medium</option>
418
+ <option value="low">Low</option>
419
+ </select>
420
+ </div>
421
+ <Input
422
+ value={newRedaction.reason}
423
+ onChange={e => setNewRedaction({ ...newRedaction, reason: e.target.value })}
424
+ placeholder="Reason for redaction"
425
+ />
426
+ <Button onClick={addCustomRedaction} size="sm">
427
+ Add Redaction
428
+ </Button>
429
+ </div>
430
+ </div>
431
+ </ScrollArea>
432
+ </div>
433
+
434
+ {/* Right: Suggestions List */}
435
+ <div className="w-96 shrink-0 flex flex-col">
436
+ <div className="px-4 py-3 border-b border-border bg-muted/30">
437
+ <p className="text-sm font-medium">Redaction Suggestions</p>
438
+ <p className="text-xs text-muted-foreground">
439
+ {acceptedSuggestions.size} accepted · {rejectedSuggestions.size} rejected
440
+ </p>
441
+ </div>
442
+ <ScrollArea className="flex-1 p-4">
443
+ <div className="space-y-3">
444
+ {analysis.suggestions.map(suggestion => {
445
+ const isAccepted = acceptedSuggestions.has(suggestion.id)
446
+ const isRejected = rejectedSuggestions.has(suggestion.id)
447
+ const Icon = SENSITIVITY_ICONS[suggestion.sensitivity_level]
448
+
449
+ return (
450
+ <div
451
+ key={suggestion.id}
452
+ className={`border rounded-lg p-3 cursor-pointer transition-colors ${
453
+ isAccepted
454
+ ? 'border-green-300 bg-green-50'
455
+ : isRejected
456
+ ? 'border-gray-300 bg-gray-50 opacity-50'
457
+ : 'border-amber-300 bg-amber-50'
458
+ }`}
459
+ onClick={() => toggleSuggestion(suggestion.id)}
460
+ >
461
+ <div className="flex items-start gap-2">
462
+ <Checkbox checked={isAccepted} className="mt-0.5" />
463
+ <div className="flex-1 min-w-0">
464
+ <div className="flex items-center gap-2 mb-1">
465
+ <Badge
466
+ variant="outline"
467
+ className={`text-xs ${SENSITIVITY_COLORS[suggestion.sensitivity_level]}`}
468
+ >
469
+ <Icon className="h-3 w-3 mr-1" />
470
+ {suggestion.sensitivity_level}
471
+ </Badge>
472
+ <span className="text-xs text-muted-foreground uppercase">{suggestion.category}</span>
473
+ </div>
474
+ <p className="text-sm font-mono truncate">"{suggestion.original_text}"</p>
475
+ <p className="text-xs text-muted-foreground mt-1">→ "{suggestion.redacted_text}"</p>
476
+ <p className="text-xs text-muted-foreground mt-2 italic">{suggestion.reasoning}</p>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ )
481
+ })}
482
+
483
+ {customRedactions.map(redaction => (
484
+ <div
485
+ key={redaction.id}
486
+ className={`border rounded-lg p-3 border-purple-300 bg-purple-50 ${
487
+ rejectedSuggestions.has(redaction.id) ? 'opacity-50' : ''
488
+ }`}
489
+ onClick={() => {
490
+ const newRejected = new Set(rejectedSuggestions)
491
+ if (newRejected.has(redaction.id)) {
492
+ newRejected.delete(redaction.id)
493
+ } else {
494
+ newRejected.add(redaction.id)
495
+ }
496
+ setRejectedSuggestions(newRejected)
497
+ }}
498
+ >
499
+ <div className="flex items-start gap-2">
500
+ <Checkbox checked={!rejectedSuggestions.has(redaction.id)} className="mt-0.5" />
501
+ <div className="flex-1 min-w-0">
502
+ <Badge variant="outline" className="text-xs">
503
+ Custom
504
+ </Badge>
505
+ <p className="text-sm font-mono mt-1 truncate">"{redaction.original_text}"</p>
506
+ <p className="text-xs text-muted-foreground mt-1">{redaction.reasoning}</p>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ ))}
511
+ </div>
512
+ </ScrollArea>
513
+
514
+ {/* Action Bar */}
515
+ <div className="border-t p-4 space-y-2">
516
+ <div className="flex items-center justify-between text-sm">
517
+ <span className="text-muted-foreground">AI Confidence</span>
518
+ <span className="font-medium">{Math.round(analysis.confidence_score * 100)}%</span>
519
+ </div>
520
+ <Button
521
+ className="w-full"
522
+ onClick={() => setViewMode('final')}
523
+ disabled={!articleTitle}
524
+ >
525
+ <CheckCircle className="h-4 w-4 mr-1" />
526
+ Preview Final
527
+ </Button>
528
+ </div>
529
+ </div>
530
+ </>
531
+ ) : (
532
+ // Final Preview Mode
533
+ <div className="flex-1 min-w-0">
534
+ <ScrollArea className="h-full p-6">
535
+ <div className="max-w-2xl mx-auto space-y-6">
536
+ <div className="space-y-2">
537
+ <h2 className="text-2xl font-bold">{articleTitle}</h2>
538
+ <p className="text-sm text-muted-foreground">
539
+ Generated from support ticket · {acceptedSuggestions.size} redactions applied
540
+ </p>
541
+ </div>
542
+
543
+ <div className="prose prose-sm max-w-none whitespace-pre-wrap border rounded-lg p-6 bg-white">
544
+ {finalContent}
545
+ </div>
546
+
547
+ <div className="flex gap-2 pt-4">
548
+ <Button variant="outline" onClick={() => setViewMode('review')}>
549
+ Back to Review
550
+ </Button>
551
+ <Button onClick={publishKB} disabled={processing || !articleTitle}>
552
+ {processing ? 'Publishing...' : 'Publish KB Article'}
553
+ </Button>
554
+ </div>
555
+ </div>
556
+ </ScrollArea>
557
+ </div>
558
+ )}
559
+ </div>
560
+ </div>
561
+ )
562
+ }