spine-framework-cortex 0.2.21 → 0.2.23
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/functions/custom_case_analysis.ts +224 -439
- package/functions/custom_kb-embeddings.ts +129 -22
- package/functions/custom_support-redaction.ts +115 -0
- package/functions/custom_support-solution.ts +104 -0
- package/manifest.json +9 -5
- package/package.json +1 -1
- package/pages/crm/AccountDetailPage.tsx +5 -2
- package/pages/crm/AccountsPage.tsx +3 -1
- package/pages/crm/DealDetailPage.tsx +5 -3
- package/pages/crm/DealsPage.tsx +4 -2
- package/pages/kb/KBEditorPage.tsx +4 -2
- package/pages/kb/KBPage.tsx +6 -4
- package/pages/support/RedactionReview.tsx +41 -21
- package/pages/support/SupportPage.tsx +4 -2
- package/pages/support/TicketDetailPage.tsx +131 -131
- package/seed/ai-agents.json +98 -0
- package/seed/pipelines.json +29 -0
- package/seed/prompt-configs.json +84 -0
- package/LICENSE.md +0 -193
- package/README.md +0 -46
- package/functions/custom_cortex-handler.ts +0 -35
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-ingestion.ts +0 -447
- package/functions/custom_tag_management.ts +0 -314
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
|
|
5
6
|
import { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
@@ -66,6 +67,7 @@ function SidebarSelect({ label, value, onChange, options }: {
|
|
|
66
67
|
export default function KBEditorPage() {
|
|
67
68
|
const { id } = useParams<{ id: string }>()
|
|
68
69
|
const navigate = useNavigate()
|
|
70
|
+
const appPath = useAppPath()
|
|
69
71
|
const isNew = !id || id === 'new'
|
|
70
72
|
const [form, setForm] = useState<ArticleForm>(EMPTY)
|
|
71
73
|
const [loading, setLoading] = useState(!isNew)
|
|
@@ -157,7 +159,7 @@ export default function KBEditorPage() {
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
navigate('/
|
|
162
|
+
navigate(appPath('/kb'))
|
|
161
163
|
} catch (e: any) {
|
|
162
164
|
setError(e.message)
|
|
163
165
|
} finally {
|
|
@@ -184,7 +186,7 @@ export default function KBEditorPage() {
|
|
|
184
186
|
{/* Header */}
|
|
185
187
|
<div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between">
|
|
186
188
|
<div className="flex items-center gap-3">
|
|
187
|
-
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/
|
|
189
|
+
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate(appPath('/kb'))}>
|
|
188
190
|
<ArrowLeft className="h-3.5 w-3.5" /> KB
|
|
189
191
|
</Button>
|
|
190
192
|
<h1 className="text-base font-semibold">{isNew ? 'New Article' : 'Edit Article'}</h1>
|
package/pages/kb/KBPage.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
5
6
|
import { Button } from '@core/components/ui/button'
|
|
@@ -49,6 +50,7 @@ const PRIORITY_COLORS: Record<string, string> = {
|
|
|
49
50
|
|
|
50
51
|
export default function KBPage() {
|
|
51
52
|
const navigate = useNavigate()
|
|
53
|
+
const appPath = useAppPath()
|
|
52
54
|
const [articles, setArticles] = useState<Article[]>([])
|
|
53
55
|
const [searchResults, setSearchResults] = useState<Article[] | null>(null)
|
|
54
56
|
const [loading, setLoading] = useState(true)
|
|
@@ -120,7 +122,7 @@ export default function KBPage() {
|
|
|
120
122
|
onChange={e => handleSearch(e.target.value)}
|
|
121
123
|
className="flex-1 h-8"
|
|
122
124
|
/>
|
|
123
|
-
<Button size="sm" className="gap-1 shrink-0" onClick={() => navigate('/
|
|
125
|
+
<Button size="sm" className="gap-1 shrink-0" onClick={() => navigate(appPath('/kb/new'))}>
|
|
124
126
|
<Plus size={14} /> New
|
|
125
127
|
</Button>
|
|
126
128
|
</div>
|
|
@@ -153,7 +155,7 @@ export default function KBPage() {
|
|
|
153
155
|
<div className="p-8 text-center text-muted-foreground">
|
|
154
156
|
<BookOpen className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
155
157
|
<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('/
|
|
158
|
+
<Button size="sm" variant="outline" className="mt-3 gap-1" onClick={() => navigate(appPath('/kb/new'))}>
|
|
157
159
|
<Plus size={13} /> Create article
|
|
158
160
|
</Button>
|
|
159
161
|
</div>
|
|
@@ -198,7 +200,7 @@ export default function KBPage() {
|
|
|
198
200
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
199
201
|
<BookOpen size={32} className="opacity-30" />
|
|
200
202
|
<p className="text-sm">Select an article to preview</p>
|
|
201
|
-
<Button size="sm" variant="outline" onClick={() => navigate('/
|
|
203
|
+
<Button size="sm" variant="outline" onClick={() => navigate(appPath('/kb/new'))} className="gap-1">
|
|
202
204
|
<Plus size={13} /> New article
|
|
203
205
|
</Button>
|
|
204
206
|
</div>
|
|
@@ -236,7 +238,7 @@ export default function KBPage() {
|
|
|
236
238
|
size="sm"
|
|
237
239
|
variant="outline"
|
|
238
240
|
className="gap-1 shrink-0"
|
|
239
|
-
onClick={() => navigate(`/
|
|
241
|
+
onClick={() => navigate(appPath(`/kb/${selected.id}/edit`))}
|
|
240
242
|
>
|
|
241
243
|
<Edit size={13} /> Edit
|
|
242
244
|
</Button>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
|
|
5
6
|
import { Button } from '@core/components/ui/button'
|
|
@@ -39,13 +40,19 @@ interface Ticket {
|
|
|
39
40
|
title: string
|
|
40
41
|
description?: string
|
|
41
42
|
data?: {
|
|
43
|
+
// Case analysis fields (populated by custom_case_analysis)
|
|
44
|
+
ca_reported_issue?: string
|
|
45
|
+
ca_true_problem?: string
|
|
46
|
+
ca_final_solution?: string
|
|
47
|
+
ca_diagnostic_steps?: string[]
|
|
48
|
+
ca_solution_steps?: string[]
|
|
49
|
+
// Legacy fields
|
|
42
50
|
ai_metadata?: {
|
|
43
51
|
problem_statement?: string
|
|
44
52
|
solution_path?: string
|
|
45
53
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
54
|
+
// KB tracking
|
|
55
|
+
kb_proposed_kb_id?: string
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -88,6 +95,7 @@ function parseContentWithRedactions(content: string, suggestions: RedactionSugge
|
|
|
88
95
|
export default function RedactionReview() {
|
|
89
96
|
const { id } = useParams<{ id: string }>()
|
|
90
97
|
const navigate = useNavigate()
|
|
98
|
+
const appPath = useAppPath()
|
|
91
99
|
const [ticket, setTicket] = useState<Ticket | null>(null)
|
|
92
100
|
const [analysis, setAnalysis] = useState<RedactionAnalysis | null>(null)
|
|
93
101
|
const [loading, setLoading] = useState(true)
|
|
@@ -118,12 +126,11 @@ export default function RedactionReview() {
|
|
|
118
126
|
setArticleTitle(`KB: ${ticketData.title}`)
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
// Trigger redaction analysis via
|
|
122
|
-
const analysisRes = await apiFetch('/
|
|
129
|
+
// Trigger redaction analysis via KB Redaction Agent
|
|
130
|
+
const analysisRes = await apiFetch('/.netlify/functions/custom_support-redaction?action=analyze', {
|
|
123
131
|
method: 'POST',
|
|
124
132
|
headers: { 'Content-Type': 'application/json' },
|
|
125
133
|
body: JSON.stringify({
|
|
126
|
-
action: 'run_redaction_analysis',
|
|
127
134
|
ticket_id: id,
|
|
128
135
|
content: buildContentForAnalysis(ticketData),
|
|
129
136
|
}),
|
|
@@ -155,16 +162,33 @@ export default function RedactionReview() {
|
|
|
155
162
|
})
|
|
156
163
|
}, [id])
|
|
157
164
|
|
|
158
|
-
// Build content from ticket for analysis
|
|
165
|
+
// Build content from ticket for redaction analysis.
|
|
166
|
+
// Prefers case analysis fields (ca_*) populated by custom_case_analysis,
|
|
167
|
+
// falling back to legacy ai_metadata fields.
|
|
159
168
|
function buildContentForAnalysis(ticket: Ticket): string {
|
|
169
|
+
const d = ticket.data || {}
|
|
160
170
|
const parts: string[] = []
|
|
161
171
|
if (ticket.title) parts.push(`Title: ${ticket.title}`)
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
if (d.ca_reported_issue) {
|
|
173
|
+
parts.push(`Reported Issue: ${d.ca_reported_issue}`)
|
|
174
|
+
} else if (ticket.description) {
|
|
175
|
+
parts.push(`Description: ${ticket.description}`)
|
|
176
|
+
}
|
|
177
|
+
if (d.ca_true_problem) {
|
|
178
|
+
parts.push(`Root Cause: ${d.ca_true_problem}`)
|
|
179
|
+
} else if (d.ai_metadata?.problem_statement) {
|
|
180
|
+
parts.push(`Problem: ${d.ai_metadata.problem_statement}`)
|
|
181
|
+
}
|
|
182
|
+
if (d.ca_diagnostic_steps?.length) {
|
|
183
|
+
parts.push(`Diagnostic Steps:\n${d.ca_diagnostic_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
|
|
165
184
|
}
|
|
166
|
-
if (
|
|
167
|
-
parts.push(`Solution
|
|
185
|
+
if (d.ca_solution_steps?.length) {
|
|
186
|
+
parts.push(`Solution Steps:\n${d.ca_solution_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
|
|
187
|
+
}
|
|
188
|
+
if (d.ca_final_solution) {
|
|
189
|
+
parts.push(`Final Solution: ${d.ca_final_solution}`)
|
|
190
|
+
} else if (d.ai_metadata?.solution_path) {
|
|
191
|
+
parts.push(`Solution: ${d.ai_metadata.solution_path}`)
|
|
168
192
|
}
|
|
169
193
|
return parts.join('\n\n')
|
|
170
194
|
}
|
|
@@ -279,21 +303,17 @@ export default function RedactionReview() {
|
|
|
279
303
|
|
|
280
304
|
// Update ticket with KB reference
|
|
281
305
|
await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
|
|
282
|
-
method: '
|
|
306
|
+
method: 'PATCH',
|
|
283
307
|
headers: { 'Content-Type': 'application/json' },
|
|
284
308
|
body: JSON.stringify({
|
|
285
309
|
data: {
|
|
286
310
|
...ticket?.data,
|
|
287
|
-
|
|
288
|
-
...ticket?.data?.postmortem,
|
|
289
|
-
kb_generated: true,
|
|
290
|
-
kb_draft_id: kbId,
|
|
291
|
-
},
|
|
311
|
+
kb_proposed_kb_id: kbId,
|
|
292
312
|
},
|
|
293
313
|
}),
|
|
294
314
|
})
|
|
295
315
|
|
|
296
|
-
navigate(`/
|
|
316
|
+
navigate(appPath(`/kb/${kbId}`))
|
|
297
317
|
}
|
|
298
318
|
} finally {
|
|
299
319
|
setProcessing(false)
|
|
@@ -316,7 +336,7 @@ export default function RedactionReview() {
|
|
|
316
336
|
<AlertTriangle className="h-5 w-5" />
|
|
317
337
|
<p>Failed to analyze content for redaction. Please try again.</p>
|
|
318
338
|
</div>
|
|
319
|
-
<Button onClick={() => navigate(`/
|
|
339
|
+
<Button onClick={() => navigate(appPath(`/support/${id}`))}>Back to Ticket</Button>
|
|
320
340
|
</div>
|
|
321
341
|
)
|
|
322
342
|
}
|
|
@@ -327,7 +347,7 @@ export default function RedactionReview() {
|
|
|
327
347
|
<div className="px-6 py-4 border-b border-border shrink-0">
|
|
328
348
|
<div className="flex items-center justify-between">
|
|
329
349
|
<div className="flex items-center gap-3">
|
|
330
|
-
<Button variant="ghost" size="sm" onClick={() => navigate(`/
|
|
350
|
+
<Button variant="ghost" size="sm" onClick={() => navigate(appPath(`/support/${id}`))}>
|
|
331
351
|
<ArrowLeft className="h-4 w-4" />
|
|
332
352
|
</Button>
|
|
333
353
|
<div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, useMemo } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { useAuth } from '@core/contexts/AuthContext'
|
|
5
6
|
import { Input } from '@core/components/ui/input'
|
|
@@ -105,6 +106,7 @@ const KANBAN_COLUMNS = [
|
|
|
105
106
|
|
|
106
107
|
export default function SupportPage() {
|
|
107
108
|
const navigate = useNavigate()
|
|
109
|
+
const appPath = useAppPath()
|
|
108
110
|
const { user } = useAuth()
|
|
109
111
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
|
110
112
|
const [loading, setLoading] = useState(true)
|
|
@@ -220,7 +222,7 @@ export default function SupportPage() {
|
|
|
220
222
|
{sorted.map(ticket => (
|
|
221
223
|
<tr
|
|
222
224
|
key={ticket.id}
|
|
223
|
-
onClick={() => navigate(`/
|
|
225
|
+
onClick={() => navigate(appPath(`/support/${ticket.id}`))}
|
|
224
226
|
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
|
225
227
|
>
|
|
226
228
|
<td className="px-5 py-3">
|
|
@@ -305,7 +307,7 @@ export default function SupportPage() {
|
|
|
305
307
|
{tickets?.map(ticket => (
|
|
306
308
|
<div
|
|
307
309
|
key={ticket.id}
|
|
308
|
-
onClick={() => navigate(`/
|
|
310
|
+
onClick={() => navigate(appPath(`/support/${ticket.id}`))}
|
|
309
311
|
className="bg-background border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-shadow"
|
|
310
312
|
>
|
|
311
313
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { Button } from '@core/components/ui/button'
|
|
5
6
|
import { Input } from '@core/components/ui/input'
|
|
@@ -7,7 +8,8 @@ import { Badge } from '@core/components/ui/badge'
|
|
|
7
8
|
import { Skeleton } from '@core/components/ui/skeleton'
|
|
8
9
|
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
9
10
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
|
|
10
|
-
import {
|
|
11
|
+
import { Textarea } from '@core/components/ui/textarea'
|
|
12
|
+
import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon, Sparkles } from 'lucide-react'
|
|
11
13
|
import { useAuth } from '@core/contexts/AuthContext'
|
|
12
14
|
|
|
13
15
|
interface Ticket {
|
|
@@ -367,145 +369,143 @@ function CaseDataPanel({ ticket }: { ticket: Ticket | null }) {
|
|
|
367
369
|
)
|
|
368
370
|
}
|
|
369
371
|
|
|
370
|
-
// AI
|
|
371
|
-
|
|
372
|
-
if (!ticket?.data?.aim_confidence_threshold && !ticket?.data?.aim_confidence_at_response && !ticket?.data?.aim_escalation_reason) {
|
|
373
|
-
return (
|
|
374
|
-
<div className="h-full flex flex-col">
|
|
375
|
-
<div className="px-4 py-2 border-b border-border bg-purple-50">
|
|
376
|
-
<p className="text-xs font-medium uppercase tracking-wide text-purple-700">AI Analysis</p>
|
|
377
|
-
</div>
|
|
378
|
-
<div className="flex-1 p-4 flex items-center justify-center text-sm text-muted-foreground">
|
|
379
|
-
No AI metadata available for this ticket.
|
|
380
|
-
</div>
|
|
381
|
-
</div>
|
|
382
|
-
)
|
|
383
|
-
}
|
|
372
|
+
// Solution AI Panel — internal AI collaborator for support agents
|
|
373
|
+
interface AIMessage { role: 'user' | 'ai'; content: string }
|
|
384
374
|
|
|
385
|
-
|
|
386
|
-
const
|
|
375
|
+
function SolutionAIPanel({ ticket, onGenerateKB }: { ticket: Ticket | null; onGenerateKB?: () => void }) {
|
|
376
|
+
const [messages, setMessages] = useState<AIMessage[]>([])
|
|
377
|
+
const [input, setInput] = useState('')
|
|
378
|
+
const [sending, setSending] = useState(false)
|
|
379
|
+
const [threadId, setThreadId] = useState<string | null>(null)
|
|
380
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
381
|
+
const ai = ticket?.data
|
|
382
|
+
const isResolved = ticket?.status === 'resolved' || ticket?.status === 'closed'
|
|
383
|
+
|
|
384
|
+
// Auto-scroll to bottom when new messages arrive
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
387
|
+
}, [messages])
|
|
388
|
+
|
|
389
|
+
const sendMessage = async () => {
|
|
390
|
+
if (!input.trim() || !ticket?.id || sending) return
|
|
391
|
+
const userText = input.trim()
|
|
392
|
+
setInput('')
|
|
393
|
+
setMessages(prev => [...prev, { role: 'user', content: userText }])
|
|
394
|
+
setSending(true)
|
|
395
|
+
try {
|
|
396
|
+
const isFirst = !threadId
|
|
397
|
+
const res = await apiFetch(
|
|
398
|
+
`/.netlify/functions/custom_support-solution?action=${isFirst ? 'start' : 'message'}`,
|
|
399
|
+
{
|
|
400
|
+
method: 'POST',
|
|
401
|
+
headers: { 'Content-Type': 'application/json' },
|
|
402
|
+
body: JSON.stringify(isFirst
|
|
403
|
+
? { ticket_id: ticket.id, message: userText }
|
|
404
|
+
: { thread_id: threadId, message: userText }
|
|
405
|
+
),
|
|
406
|
+
}
|
|
407
|
+
).then(r => r.json())
|
|
408
|
+
|
|
409
|
+
if (res.threadId && !threadId) setThreadId(res.threadId)
|
|
410
|
+
if (res.content) setMessages(prev => [...prev, { role: 'ai', content: res.content }])
|
|
411
|
+
} catch (err) {
|
|
412
|
+
setMessages(prev => [...prev, { role: 'ai', content: 'Error contacting Solution AI. Please try again.' }])
|
|
413
|
+
} finally {
|
|
414
|
+
setSending(false)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
387
417
|
|
|
388
418
|
return (
|
|
389
419
|
<div className="h-full flex flex-col">
|
|
390
|
-
|
|
391
|
-
|
|
420
|
+
{/* Header */}
|
|
421
|
+
<div className="px-4 py-2 border-b border-border bg-purple-50 shrink-0">
|
|
422
|
+
<div className="flex items-center justify-between">
|
|
423
|
+
<div className="flex items-center gap-1.5">
|
|
424
|
+
<Sparkles className="h-3.5 w-3.5 text-purple-600" />
|
|
425
|
+
<p className="text-xs font-medium uppercase tracking-wide text-purple-700">Solution AI</p>
|
|
426
|
+
</div>
|
|
427
|
+
<p className="text-xs text-purple-500">Internal only</p>
|
|
428
|
+
</div>
|
|
392
429
|
</div>
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
430
|
+
|
|
431
|
+
{/* Triage metadata strip */}
|
|
432
|
+
{(ai?.aim_confidence_at_response || ai?.aim_escalation_reason) && (
|
|
433
|
+
<div className="px-3 py-1.5 border-b border-border bg-muted/40 shrink-0 flex items-center gap-3 text-xs text-muted-foreground">
|
|
396
434
|
{ai.aim_confidence_at_response && (
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
<span className={`font-mono font-medium ${ai.aim_confidence_at_response >= 0.75 ? 'text-green-600' : 'text-amber-600'}`}>
|
|
401
|
-
{Math.round(ai.aim_confidence_at_response * 100)}%
|
|
402
|
-
</span>
|
|
403
|
-
</div>
|
|
404
|
-
{ai.aim_confidence_threshold && (
|
|
405
|
-
<p className="text-xs text-muted-foreground">Threshold: {Math.round(ai.aim_confidence_threshold * 100)}%</p>
|
|
406
|
-
)}
|
|
407
|
-
</div>
|
|
435
|
+
<span className={ai.aim_confidence_at_response >= 0.75 ? 'text-green-600' : 'text-amber-600'}>
|
|
436
|
+
Triage confidence: {Math.round(ai.aim_confidence_at_response * 100)}%
|
|
437
|
+
</span>
|
|
408
438
|
)}
|
|
409
|
-
|
|
410
439
|
{ai.aim_escalation_reason && ai.aim_escalation_reason !== 'none' && (
|
|
411
|
-
<
|
|
412
|
-
<AlertCircle className="h-
|
|
413
|
-
|
|
414
|
-
<p className="font-medium text-amber-700">Escalated</p>
|
|
415
|
-
<p className="text-amber-600 text-xs">{ai.aim_escalation_reason.replace('_', ' ')}</p>
|
|
416
|
-
</div>
|
|
417
|
-
</div>
|
|
418
|
-
)}
|
|
419
|
-
|
|
420
|
-
{/* Problem Statement */}
|
|
421
|
-
{ai.aim_problem_statement && (
|
|
422
|
-
<div className="space-y-1">
|
|
423
|
-
<p className="text-xs font-medium text-muted-foreground uppercase">Problem</p>
|
|
424
|
-
<p className="text-sm bg-muted p-2 rounded">{ai.aim_problem_statement}</p>
|
|
425
|
-
</div>
|
|
440
|
+
<span className="text-amber-600 flex items-center gap-1">
|
|
441
|
+
<AlertCircle className="h-3 w-3" /> Escalated
|
|
442
|
+
</span>
|
|
426
443
|
)}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{/* Chat history */}
|
|
448
|
+
<ScrollArea className="flex-1 px-3 py-2">
|
|
449
|
+
{messages.length === 0 ? (
|
|
450
|
+
<div className="flex flex-col items-center justify-center h-32 text-center text-muted-foreground text-xs gap-2">
|
|
451
|
+
<Bot className="h-6 w-6" />
|
|
452
|
+
<p>Ask Solution AI for help with this case.</p>
|
|
453
|
+
<p className="text-xs opacity-70">Searches KB · recalls similar cases · drafts replies</p>
|
|
454
|
+
</div>
|
|
455
|
+
) : (
|
|
456
|
+
<div className="space-y-2">
|
|
457
|
+
{messages.map((msg, i) => (
|
|
458
|
+
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
459
|
+
<div className={`max-w-[90%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap ${
|
|
460
|
+
msg.role === 'user'
|
|
461
|
+
? 'bg-primary text-primary-foreground'
|
|
462
|
+
: 'bg-muted text-foreground'
|
|
463
|
+
}`}>
|
|
464
|
+
{msg.content}
|
|
465
|
+
</div>
|
|
444
466
|
</div>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
<>
|
|
451
|
-
<Separator />
|
|
452
|
-
<div className="space-y-3">
|
|
453
|
-
<p className="text-xs font-medium text-muted-foreground uppercase">Post-mortem</p>
|
|
454
|
-
|
|
455
|
-
{postmortem?.root_cause_category && (
|
|
456
|
-
<p className="text-sm">
|
|
457
|
-
<span className="text-muted-foreground">Root cause:</span>{' '}
|
|
458
|
-
{postmortem.root_cause_category}
|
|
459
|
-
</p>
|
|
460
|
-
)}
|
|
461
|
-
|
|
462
|
-
{postmortem?.resolution_time_minutes && (
|
|
463
|
-
<p className="text-sm">
|
|
464
|
-
<span className="text-muted-foreground">Resolution time:</span>{' '}
|
|
465
|
-
{Math.round(postmortem.resolution_time_minutes)} min
|
|
466
|
-
</p>
|
|
467
|
-
)}
|
|
468
|
-
|
|
469
|
-
{postmortem?.customer_satisfaction !== undefined && (
|
|
470
|
-
<div className="flex items-center gap-2">
|
|
471
|
-
<span className="text-sm text-muted-foreground">Satisfaction:</span>
|
|
472
|
-
<div className="flex">
|
|
473
|
-
{[1, 2, 3, 4, 5].map((star) => (
|
|
474
|
-
<span
|
|
475
|
-
key={star}
|
|
476
|
-
className={`text-lg ${star <= (postmortem.customer_satisfaction || 0) ? 'text-amber-400' : 'text-gray-300'}`}
|
|
477
|
-
>
|
|
478
|
-
★
|
|
479
|
-
</span>
|
|
480
|
-
))}
|
|
481
|
-
</div>
|
|
482
|
-
</div>
|
|
483
|
-
)}
|
|
484
|
-
|
|
485
|
-
{/* KB Generation */}
|
|
486
|
-
<div className="pt-2">
|
|
487
|
-
{postmortem?.kb_generated ? (
|
|
488
|
-
<div className="flex items-center gap-2 text-green-600 text-sm">
|
|
489
|
-
<CheckCircle className="h-4 w-4" />
|
|
490
|
-
KB article created
|
|
491
|
-
</div>
|
|
492
|
-
) : (
|
|
493
|
-
<Button
|
|
494
|
-
variant="outline"
|
|
495
|
-
size="sm"
|
|
496
|
-
className="w-full gap-1 text-xs"
|
|
497
|
-
onClick={onGenerateKB}
|
|
498
|
-
>
|
|
499
|
-
<BookOpen className="h-3 w-3" />
|
|
500
|
-
Generate KB Article
|
|
501
|
-
</Button>
|
|
502
|
-
)}
|
|
467
|
+
))}
|
|
468
|
+
{sending && (
|
|
469
|
+
<div className="flex justify-start">
|
|
470
|
+
<div className="bg-muted rounded-lg px-3 py-2 text-xs text-muted-foreground animate-pulse">
|
|
471
|
+
Thinking…
|
|
503
472
|
</div>
|
|
504
473
|
</div>
|
|
505
|
-
|
|
474
|
+
)}
|
|
475
|
+
<div ref={bottomRef} />
|
|
476
|
+
</div>
|
|
477
|
+
)}
|
|
478
|
+
</ScrollArea>
|
|
479
|
+
|
|
480
|
+
{/* KB generation (resolved only) */}
|
|
481
|
+
{isResolved && (
|
|
482
|
+
<div className="px-3 py-2 border-t border-border shrink-0">
|
|
483
|
+
{ai?.kb_proposed_kb_id ? (
|
|
484
|
+
<div className="flex items-center gap-2 text-green-600 text-xs">
|
|
485
|
+
<CheckCircle className="h-3.5 w-3.5" /> KB article created
|
|
486
|
+
</div>
|
|
487
|
+
) : (
|
|
488
|
+
<Button variant="outline" size="sm" className="w-full gap-1 text-xs" onClick={onGenerateKB}>
|
|
489
|
+
<BookOpen className="h-3 w-3" /> Generate KB Article
|
|
490
|
+
</Button>
|
|
506
491
|
)}
|
|
507
492
|
</div>
|
|
508
|
-
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{/* Input */}
|
|
496
|
+
<div className="px-3 py-2 border-t border-border shrink-0 flex gap-2">
|
|
497
|
+
<Textarea
|
|
498
|
+
value={input}
|
|
499
|
+
onChange={e => setInput(e.target.value)}
|
|
500
|
+
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }}
|
|
501
|
+
placeholder="Ask Solution AI…"
|
|
502
|
+
className="flex-1 text-xs resize-none min-h-[36px] max-h-[100px]"
|
|
503
|
+
rows={1}
|
|
504
|
+
/>
|
|
505
|
+
<Button size="sm" onClick={sendMessage} disabled={!input.trim() || sending} className="shrink-0">
|
|
506
|
+
<Send className="h-3.5 w-3.5" />
|
|
507
|
+
</Button>
|
|
508
|
+
</div>
|
|
509
509
|
</div>
|
|
510
510
|
)
|
|
511
511
|
}
|
|
@@ -729,6 +729,7 @@ function Separator() {
|
|
|
729
729
|
export default function TicketDetailPage() {
|
|
730
730
|
const { id } = useParams<{ id: string }>()
|
|
731
731
|
const navigate = useNavigate()
|
|
732
|
+
const appPath = useAppPath()
|
|
732
733
|
const { user } = useAuth()
|
|
733
734
|
const [ticket, setTicket] = useState<Ticket | null>(null)
|
|
734
735
|
const [threads, setThreads] = useState<Thread[]>([])
|
|
@@ -838,8 +839,7 @@ export default function TicketDetailPage() {
|
|
|
838
839
|
}
|
|
839
840
|
|
|
840
841
|
const handleGenerateKB = () => {
|
|
841
|
-
|
|
842
|
-
navigate(`/cortex/support/${id}/kb-review`)
|
|
842
|
+
navigate(appPath(`/support/${id}/kb-review`))
|
|
843
843
|
}
|
|
844
844
|
|
|
845
845
|
const externalThread = threads.find(t => !t.visibility || t.visibility === 'external') ?? null
|
|
@@ -852,7 +852,7 @@ export default function TicketDetailPage() {
|
|
|
852
852
|
<div className="flex flex-col h-full">
|
|
853
853
|
<div className="px-6 py-4 border-b border-border shrink-0 flex items-center justify-between">
|
|
854
854
|
<div className="flex items-center gap-3">
|
|
855
|
-
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/
|
|
855
|
+
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate(appPath('/support'))}>
|
|
856
856
|
<ArrowLeft className="h-3.5 w-3.5" /> Support
|
|
857
857
|
</Button>
|
|
858
858
|
<div>
|
|
@@ -910,7 +910,7 @@ export default function TicketDetailPage() {
|
|
|
910
910
|
</TabsList>
|
|
911
911
|
</Tabs>
|
|
912
912
|
{activeSidePanel === 'case' && <CaseDataPanel ticket={ticket} />}
|
|
913
|
-
{activeSidePanel === 'ai' && <
|
|
913
|
+
{activeSidePanel === 'ai' && <SolutionAIPanel ticket={ticket} onGenerateKB={handleGenerateKB} />}
|
|
914
914
|
{activeSidePanel === 'analysis' && <CaseAnalysisPanel ticket={ticket} analysisLoading={analysisLoading} />}
|
|
915
915
|
</div>
|
|
916
916
|
</div>
|