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,328 @@
|
|
|
1
|
+
import { useState, useEffect } 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 { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
6
|
+
import { Button } from '@core/components/ui/button'
|
|
7
|
+
import { Input } from '@core/components/ui/input'
|
|
8
|
+
import { Label } from '@core/components/ui/label'
|
|
9
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
10
|
+
import { ArrowLeft, Save } from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
interface ArticleForm {
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
status: string
|
|
16
|
+
kb_type: string
|
|
17
|
+
priority: string
|
|
18
|
+
category: string
|
|
19
|
+
security_level: string
|
|
20
|
+
tags: string
|
|
21
|
+
audience: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EMPTY: ArticleForm = {
|
|
25
|
+
title: '',
|
|
26
|
+
description: '',
|
|
27
|
+
status: 'draft',
|
|
28
|
+
kb_type: 'article',
|
|
29
|
+
priority: 'medium',
|
|
30
|
+
category: '',
|
|
31
|
+
security_level: 'internal',
|
|
32
|
+
tags: '',
|
|
33
|
+
audience: [],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AUDIENCE_OPTIONS = [
|
|
37
|
+
{ value: 'end_user', label: 'End User' },
|
|
38
|
+
{ value: 'support_agent', label: 'Support Agent' },
|
|
39
|
+
{ value: 'developer', label: 'Developer' },
|
|
40
|
+
{ value: 'admin', label: 'Administrator' },
|
|
41
|
+
{ value: 'manager', label: 'Manager' },
|
|
42
|
+
{ value: 'ai_system', label: 'AI System' },
|
|
43
|
+
{ value: 'internal_only', label: 'Internal Only' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
function SidebarSelect({ label, value, onChange, options }: {
|
|
47
|
+
label: string
|
|
48
|
+
value: string
|
|
49
|
+
onChange: (v: string) => void
|
|
50
|
+
options: { value: string; label: string }[]
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="space-y-1.5">
|
|
54
|
+
<Label className="text-xs">{label}</Label>
|
|
55
|
+
<select
|
|
56
|
+
value={value}
|
|
57
|
+
onChange={e => onChange(e.target.value)}
|
|
58
|
+
className="w-full border border-border rounded px-2.5 py-1.5 text-sm bg-background"
|
|
59
|
+
>
|
|
60
|
+
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
61
|
+
</select>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function KBEditorPage() {
|
|
67
|
+
const { id } = useParams<{ id: string }>()
|
|
68
|
+
const navigate = useNavigate()
|
|
69
|
+
const isNew = !id || id === 'new'
|
|
70
|
+
const [form, setForm] = useState<ArticleForm>(EMPTY)
|
|
71
|
+
const [loading, setLoading] = useState(!isNew)
|
|
72
|
+
const [saving, setSaving] = useState(false)
|
|
73
|
+
const [error, setError] = useState<string | null>(null)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (isNew) return
|
|
77
|
+
apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`)
|
|
78
|
+
.then(r => r.json())
|
|
79
|
+
.then(raw => {
|
|
80
|
+
const j = raw?.data ?? raw
|
|
81
|
+
if (j) setForm({
|
|
82
|
+
title: j.title || '',
|
|
83
|
+
description: j.description || '',
|
|
84
|
+
status: j.status || 'draft',
|
|
85
|
+
kb_type: j.data?.kb_type || 'article',
|
|
86
|
+
priority: j.data?.priority || 'medium',
|
|
87
|
+
category: j.data?.category || '',
|
|
88
|
+
security_level: j.data?.security_level || 'internal',
|
|
89
|
+
tags: (() => { const t = j.data?.tags; if (Array.isArray(t)) return t.join(', '); if (typeof t === 'string') { try { const p = JSON.parse(t); return Array.isArray(p) ? p.join(', ') : t } catch { return t } } return '' })(),
|
|
90
|
+
audience: Array.isArray(j.data?.audience) ? j.data.audience : [],
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
.catch(() => setError('Failed to load article'))
|
|
94
|
+
.finally(() => setLoading(false))
|
|
95
|
+
}, [id, isNew])
|
|
96
|
+
|
|
97
|
+
const handleSave = async (publish?: boolean) => {
|
|
98
|
+
if (!form.title.trim()) { setError('Title is required'); return }
|
|
99
|
+
if (!form.kb_type) { setError('KB Type is required'); return }
|
|
100
|
+
setSaving(true)
|
|
101
|
+
setError(null)
|
|
102
|
+
try {
|
|
103
|
+
const kbArticleTypeId = await resolveTypeId('kb_article')
|
|
104
|
+
const payload = {
|
|
105
|
+
title: form.title.trim(),
|
|
106
|
+
description: form.description,
|
|
107
|
+
type_id: kbArticleTypeId,
|
|
108
|
+
status: publish ? 'published' : form.status,
|
|
109
|
+
data: {
|
|
110
|
+
kb_type: form.kb_type,
|
|
111
|
+
priority: form.priority,
|
|
112
|
+
category: form.category.trim() || null,
|
|
113
|
+
security_level: form.security_level,
|
|
114
|
+
tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
|
115
|
+
audience: form.audience,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
let res: Response
|
|
119
|
+
if (isNew) {
|
|
120
|
+
res = await apiFetch('/api/admin-data?action=create&entity=items', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify(payload),
|
|
124
|
+
})
|
|
125
|
+
} else {
|
|
126
|
+
res = await apiFetch(`/api/admin-data?entity=items&id=${id}`, {
|
|
127
|
+
method: 'PATCH',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify(payload),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) { const e = await res.clone().json().catch(() => ({})); throw new Error(e?.error || 'Save failed') }
|
|
133
|
+
|
|
134
|
+
// Auto-generate embeddings for the saved article
|
|
135
|
+
const saved = await res.json().catch(() => null)
|
|
136
|
+
const itemId = isNew ? (saved?.id || saved?.data?.id) : id
|
|
137
|
+
if (itemId) {
|
|
138
|
+
try {
|
|
139
|
+
await apiFetch('/api/custom_kb-embeddings?action=generate', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
item_ids: [itemId],
|
|
144
|
+
vector_types: ['semantic', 'structure'],
|
|
145
|
+
force_regenerate: true,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
} catch (embErr) {
|
|
149
|
+
console.warn('Embedding generation failed (article saved successfully):', embErr)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
navigate('/cortex/kb')
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
setError(e.message)
|
|
156
|
+
} finally {
|
|
157
|
+
setSaving(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const toggleAudience = (val: string) => {
|
|
162
|
+
setForm(f => ({
|
|
163
|
+
...f,
|
|
164
|
+
audience: f.audience.includes(val) ? f.audience.filter(a => a !== val) : [...f.audience, val],
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (loading) return (
|
|
169
|
+
<div className="p-6 space-y-4">
|
|
170
|
+
<Skeleton className="h-8 w-64" />
|
|
171
|
+
<Skeleton className="h-64 w-full" />
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex flex-col h-full">
|
|
177
|
+
{/* Header */}
|
|
178
|
+
<div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between">
|
|
179
|
+
<div className="flex items-center gap-3">
|
|
180
|
+
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/cortex/kb')}>
|
|
181
|
+
<ArrowLeft className="h-3.5 w-3.5" /> KB
|
|
182
|
+
</Button>
|
|
183
|
+
<h1 className="text-base font-semibold">{isNew ? 'New Article' : 'Edit Article'}</h1>
|
|
184
|
+
</div>
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<Button variant="outline" size="sm" onClick={() => handleSave(false)} disabled={saving}>
|
|
187
|
+
<Save className="h-3.5 w-3.5 mr-1" /> Save
|
|
188
|
+
</Button>
|
|
189
|
+
<Button size="sm" onClick={() => handleSave(true)} disabled={saving}>
|
|
190
|
+
{saving ? 'Saving…' : 'Publish'}
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{error && (
|
|
196
|
+
<div className="mx-6 mt-4 px-4 py-3 bg-destructive/10 border border-destructive/20 text-destructive rounded text-sm">
|
|
197
|
+
{error}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
<div className="flex flex-1 min-h-0">
|
|
202
|
+
{/* Main content area */}
|
|
203
|
+
<div className="flex-1 p-6 space-y-4 overflow-y-auto">
|
|
204
|
+
<div className="space-y-1.5">
|
|
205
|
+
<Label>Title *</Label>
|
|
206
|
+
<Input
|
|
207
|
+
value={form.title}
|
|
208
|
+
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
|
209
|
+
placeholder="Article title"
|
|
210
|
+
className="text-lg font-medium"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<div className="space-y-1.5 flex-1">
|
|
214
|
+
<Label>Content *</Label>
|
|
215
|
+
<RichTextEditor
|
|
216
|
+
value={form.description}
|
|
217
|
+
onChange={html => setForm(f => ({ ...f, description: html }))}
|
|
218
|
+
placeholder="Write your article content here…"
|
|
219
|
+
minHeight="400px"
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{/* Sidebar */}
|
|
225
|
+
<div className="w-64 shrink-0 border-l border-border p-4 space-y-4 overflow-y-auto">
|
|
226
|
+
<SidebarSelect
|
|
227
|
+
label="Status"
|
|
228
|
+
value={form.status}
|
|
229
|
+
onChange={v => setForm(f => ({ ...f, status: v }))}
|
|
230
|
+
options={[
|
|
231
|
+
{ value: 'draft', label: 'Draft' },
|
|
232
|
+
{ value: 'review', label: 'Under Review' },
|
|
233
|
+
{ value: 'published', label: 'Published' },
|
|
234
|
+
{ value: 'deprecated', label: 'Deprecated' },
|
|
235
|
+
{ value: 'archived', label: 'Archived' },
|
|
236
|
+
{ value: 'restricted', label: 'Restricted Access' },
|
|
237
|
+
]}
|
|
238
|
+
/>
|
|
239
|
+
<SidebarSelect
|
|
240
|
+
label="KB Type *"
|
|
241
|
+
value={form.kb_type}
|
|
242
|
+
onChange={v => setForm(f => ({ ...f, kb_type: v }))}
|
|
243
|
+
options={[
|
|
244
|
+
{ value: 'article', label: 'Article' },
|
|
245
|
+
{ value: 'care_guide', label: 'Care Guide' },
|
|
246
|
+
{ value: 'process_guide', label: 'Process Guide' },
|
|
247
|
+
{ value: 'code_chunk', label: 'Code Chunk' },
|
|
248
|
+
{ value: 'api_reference', label: 'API Reference' },
|
|
249
|
+
{ value: 'troubleshooting', label: 'Troubleshooting' },
|
|
250
|
+
{ value: 'faq', label: 'FAQ' },
|
|
251
|
+
{ value: 'policy', label: 'Policy' },
|
|
252
|
+
{ value: 'tutorial', label: 'Tutorial' },
|
|
253
|
+
]}
|
|
254
|
+
/>
|
|
255
|
+
<SidebarSelect
|
|
256
|
+
label="Priority *"
|
|
257
|
+
value={form.priority}
|
|
258
|
+
onChange={v => setForm(f => ({ ...f, priority: v }))}
|
|
259
|
+
options={[
|
|
260
|
+
{ value: 'low', label: 'Low' },
|
|
261
|
+
{ value: 'medium', label: 'Medium' },
|
|
262
|
+
{ value: 'high', label: 'High' },
|
|
263
|
+
{ value: 'critical', label: 'Critical' },
|
|
264
|
+
{ value: 'emergency', label: 'Emergency' },
|
|
265
|
+
]}
|
|
266
|
+
/>
|
|
267
|
+
<SidebarSelect
|
|
268
|
+
label="Category"
|
|
269
|
+
value={form.category}
|
|
270
|
+
onChange={v => setForm(f => ({ ...f, category: v }))}
|
|
271
|
+
options={[
|
|
272
|
+
{ value: '', label: '— None —' },
|
|
273
|
+
{ value: 'getting_started', label: 'Getting Started' },
|
|
274
|
+
{ value: 'user_guide', label: 'User Guide' },
|
|
275
|
+
{ value: 'technical', label: 'Technical' },
|
|
276
|
+
{ value: 'billing', label: 'Billing' },
|
|
277
|
+
{ value: 'security', label: 'Security' },
|
|
278
|
+
{ value: 'integration', label: 'Integration' },
|
|
279
|
+
{ value: 'troubleshooting', label: 'Troubleshooting' },
|
|
280
|
+
{ value: 'reference', label: 'Reference' },
|
|
281
|
+
{ value: 'best_practices', label: 'Best Practices' },
|
|
282
|
+
{ value: 'policies', label: 'Policies' },
|
|
283
|
+
]}
|
|
284
|
+
/>
|
|
285
|
+
<SidebarSelect
|
|
286
|
+
label="Security Level"
|
|
287
|
+
value={form.security_level}
|
|
288
|
+
onChange={v => setForm(f => ({ ...f, security_level: v }))}
|
|
289
|
+
options={[
|
|
290
|
+
{ value: 'public', label: 'Public' },
|
|
291
|
+
{ value: 'internal', label: 'Internal' },
|
|
292
|
+
{ value: 'confidential', label: 'Confidential' },
|
|
293
|
+
{ value: 'restricted', label: 'Restricted' },
|
|
294
|
+
]}
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
<div className="space-y-1.5">
|
|
298
|
+
<Label className="text-xs">Tags</Label>
|
|
299
|
+
<Input
|
|
300
|
+
value={form.tags}
|
|
301
|
+
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
|
302
|
+
placeholder="tag1, tag2, tag3"
|
|
303
|
+
className="text-sm"
|
|
304
|
+
/>
|
|
305
|
+
<p className="text-xs text-muted-foreground">Comma-separated</p>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<div className="space-y-1.5">
|
|
309
|
+
<Label className="text-xs">Audience</Label>
|
|
310
|
+
<div className="space-y-1">
|
|
311
|
+
{AUDIENCE_OPTIONS.map(opt => (
|
|
312
|
+
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
|
313
|
+
<input
|
|
314
|
+
type="checkbox"
|
|
315
|
+
checked={form.audience.includes(opt.value)}
|
|
316
|
+
onChange={() => toggleAudience(opt.value)}
|
|
317
|
+
className="rounded border-border"
|
|
318
|
+
/>
|
|
319
|
+
<span className="text-xs">{opt.label}</span>
|
|
320
|
+
</label>
|
|
321
|
+
))}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|