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.
@@ -4,7 +4,7 @@ import { chunkArticle, htmlToPlainText } from './custom_kb-chunker'
4
4
 
5
5
  interface KBEmbeddingRequest {
6
6
  item_id: string
7
- vector_types: ('semantic' | 'structure' | 'code')[]
7
+ vector_types: ('semantic' | 'structure' | 'summary' | 'code')[]
8
8
  force_regenerate?: boolean
9
9
  }
10
10
 
@@ -109,6 +109,15 @@ async function generateEmbeddingBatch(inputs: string[]): Promise<string[]> {
109
109
  return sorted.map((d: any) => `[${d.embedding.join(',')}]`)
110
110
  }
111
111
 
112
+ // Resolve platform account ID at runtime (slug: 'spine-system')
113
+ let _platformAccountId: string | null = null
114
+ async function getPlatformAccountId(): Promise<string | null> {
115
+ if (_platformAccountId) return _platformAccountId
116
+ const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
117
+ _platformAccountId = data?.id ?? null
118
+ return _platformAccountId
119
+ }
120
+
112
121
  // Insert an embedding record (used during batch writes)
113
122
  async function insertEmbedding(
114
123
  itemId: string,
@@ -116,12 +125,14 @@ async function insertEmbedding(
116
125
  chunkIndex: number,
117
126
  content: string,
118
127
  embedding: string,
119
- metadata: any
128
+ metadata: any,
129
+ accountId?: string
120
130
  ): Promise<void> {
131
+ const resolvedAccountId = accountId ?? await getPlatformAccountId()
121
132
  await adminDb
122
133
  .from('embeddings')
123
134
  .insert({
124
- account_id: '12acec9b-8451-40e7-80d5-e80c4e2fc0de', // Master account
135
+ account_id: resolvedAccountId,
125
136
  model_id: 'text-embedding-3-small',
126
137
  document_id: itemId,
127
138
  chunk_index: chunkIndex,
@@ -135,6 +146,33 @@ async function insertEmbedding(
135
146
  })
136
147
  }
137
148
 
149
+ // Generate a one-paragraph AI summary for top-level retrieval matching
150
+ async function generateArticleSummary(item: any): Promise<string> {
151
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
152
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
153
+ if (!apiKey) return `${item.title || ''}: ${(item.description || '').slice(0, 300)}`
154
+
155
+ const plainText = (item.description || '')
156
+ .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 3000)
157
+
158
+ const res = await fetch(`${baseUrl}/chat/completions`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
161
+ body: JSON.stringify({
162
+ model: process.env.LLM_DEFAULT_MODEL || 'gpt-4o-mini',
163
+ temperature: 0.2,
164
+ max_tokens: 120,
165
+ messages: [
166
+ { role: 'system', content: 'Write a single concise paragraph (max 100 words) summarising this knowledge base article for search indexing. Output only the paragraph.' },
167
+ { role: 'user', content: `Title: ${item.title}\n\n${plainText}` }
168
+ ]
169
+ })
170
+ })
171
+ if (!res.ok) return `${item.title}: ${plainText.slice(0, 200)}`
172
+ const result: any = await res.json()
173
+ return result.choices?.[0]?.message?.content?.trim() || `${item.title}: ${plainText.slice(0, 200)}`
174
+ }
175
+
138
176
  // Delete all existing embeddings for a document (called before re-embedding)
139
177
  async function deleteExistingEmbeddings(itemId: string): Promise<number> {
140
178
  const { data } = await adminDb
@@ -169,7 +207,8 @@ function isCodeChunk(item: any): boolean {
169
207
  async function handleGenerateEmbeddings(
170
208
  itemId: string,
171
209
  vectorTypes: string[],
172
- forceRegenerate: boolean = false
210
+ forceRegenerate: boolean = false,
211
+ overrideAccountId?: string
173
212
  ): Promise<KBEmbeddingResponse> {
174
213
  const response: KBEmbeddingResponse = {
175
214
  success: true,
@@ -179,18 +218,20 @@ async function handleGenerateEmbeddings(
179
218
  }
180
219
 
181
220
  try {
182
- // Get the KB item
221
+ // Get the KB item — allow any type when called from case resolution path
183
222
  const { data: item, error: itemError } = await adminDb
184
223
  .from('items')
185
224
  .select('*')
186
225
  .eq('id', itemId)
187
- .eq('type_id', 'ce1e50b6-473e-4581-ba0c-e944f47cb240') // kb_article type
188
226
  .single()
189
227
 
190
228
  if (itemError || !item) {
191
- throw new Error('KB item not found')
229
+ throw new Error('Item not found for embedding')
192
230
  }
193
231
 
232
+ // Resolve account: caller override > item's own account > platform account
233
+ const accountId = overrideAccountId || item.account_id || await getPlatformAccountId()
234
+
194
235
  // Check if embeddings already exist
195
236
  if (!forceRegenerate) {
196
237
  const { data: existing } = await adminDb
@@ -213,7 +254,7 @@ async function handleGenerateEmbeddings(
213
254
  response.embeddings_updated = deleted
214
255
  }
215
256
 
216
- // ── Code chunks: legacy path (single semantic + single structure) ──
257
+ // ── Code chunks: legacy path (semantic + structure) ──
217
258
  if (isCodeChunk(item)) {
218
259
  const semanticContent = buildSemanticContent(item)
219
260
  const structureContent = buildStructureContent(item)
@@ -226,33 +267,32 @@ async function handleGenerateEmbeddings(
226
267
  chunk_id: item.data?.code_metadata?.chunk_id,
227
268
  chunk_type: item.data?.code_metadata?.chunk_type,
228
269
  file_path: item.data?.code_metadata?.file_path,
229
- }),
270
+ }, accountId),
230
271
  insertEmbedding(itemId, 'structure', 0, structureContent, vectors[1], {
231
272
  kb_type: item.data?.kb_type,
232
273
  tags: item.data?.tags,
233
274
  file_path: item.data?.code_metadata?.file_path,
234
275
  depends_on: item.data?.code_metadata?.depends_on,
235
- }),
276
+ }, accountId),
236
277
  ])
237
278
 
238
279
  response.embeddings_created = 2
239
280
  return response
240
281
  }
241
282
 
242
- // ── Article path: chunk batch embed parallel write ──
283
+ // ── Article path: semantic chunks + structure + summary ──
243
284
  const articleContent = item.description || ''
244
- const chunks = chunkArticle(articleContent, {
245
- articleTitle: item.title || 'Untitled',
246
- })
285
+ const chunks = chunkArticle(articleContent, { articleTitle: item.title || 'Untitled' })
247
286
 
248
- // Build all texts to embed: N semantic chunks + 1 structure
249
287
  const structureContent = buildArticleStructureContent(item)
250
- const allTexts = [...chunks.map(c => c.content), structureContent]
251
288
 
252
- // Single batched API call to OpenAI
289
+ // Generate AI summary for top-level retrieval matching
290
+ const summaryContent = await generateArticleSummary(item)
291
+
292
+ // Single batched embed: N semantic chunks + 1 structure + 1 summary
293
+ const allTexts = [...chunks.map(c => c.content), structureContent, summaryContent]
253
294
  const allVectors = await generateEmbeddingBatch(allTexts)
254
295
 
255
- // Parallel DB writes
256
296
  const writePromises: Promise<void>[] = []
257
297
 
258
298
  // Semantic embeddings — one per chunk
@@ -263,22 +303,32 @@ async function handleGenerateEmbeddings(
263
303
  chunk_index: i,
264
304
  chunk_total: chunks.length,
265
305
  section_path: chunks[i].sectionPath,
266
- })
306
+ security_level: item.data?.security_level,
307
+ }, accountId)
267
308
  )
268
309
  }
269
310
 
270
- // Structure embedding — article-level, single vector
311
+ // Structure embedding — facets for filtering
271
312
  writePromises.push(
272
313
  insertEmbedding(itemId, 'structure', 0, structureContent, allVectors[chunks.length], {
273
314
  kb_type: item.data?.kb_type,
274
315
  tags: item.data?.tags,
275
316
  category: item.data?.category,
276
317
  audience: item.data?.audience,
277
- })
318
+ security_level: item.data?.security_level,
319
+ }, accountId)
320
+ )
321
+
322
+ // Summary embedding — one-paragraph AI summary for top-level matching
323
+ writePromises.push(
324
+ insertEmbedding(itemId, 'summary', 0, summaryContent, allVectors[chunks.length + 1], {
325
+ kb_type: item.data?.kb_type,
326
+ security_level: item.data?.security_level,
327
+ }, accountId)
278
328
  )
279
329
 
280
330
  await Promise.all(writePromises)
281
- response.embeddings_created = chunks.length + 1
331
+ response.embeddings_created = chunks.length + 2 // +2 for structure + summary
282
332
 
283
333
  } catch (error) {
284
334
  response.success = false
@@ -288,6 +338,63 @@ async function handleGenerateEmbeddings(
288
338
  return response
289
339
  }
290
340
 
341
+ /**
342
+ * Embeds a resolved ticket's case data as a knowledge source for future triage RAG.
343
+ * Called automatically when a ticket is resolved (via case analysis completion).
344
+ *
345
+ * Stores two vectors:
346
+ * - 'semantic': ca_true_problem + ca_final_solution (the actual answer)
347
+ * - 'summary': ca_reported_issue (how the customer described it — improves matching)
348
+ *
349
+ * These are stored with item_type: 'resolved_case' so they can be included in
350
+ * triage agent knowledge_sources without polluting the main KB article pool.
351
+ */
352
+ export async function embedResolvedCase(ticketId: string, accountId: string): Promise<void> {
353
+ // Load ticket data fields
354
+ const { data: ticket } = await adminDb
355
+ .from('items')
356
+ .select('id, title, data')
357
+ .eq('id', ticketId)
358
+ .single()
359
+
360
+ if (!ticket) return
361
+
362
+ const trueProb = ticket.data?.ca_true_problem || ''
363
+ const finalSol = ticket.data?.ca_final_solution || ''
364
+ const reportedIssue = ticket.data?.ca_reported_issue || ticket.title || ''
365
+
366
+ if (!trueProb && !finalSol) return // nothing to embed
367
+
368
+ // Delete any pre-existing resolved_case embeddings for this ticket
369
+ await adminDb
370
+ .from('embeddings')
371
+ .delete()
372
+ .eq('document_id', ticketId)
373
+ .eq('metadata->>item_type', 'resolved_case')
374
+
375
+ const semanticContent = `Problem: ${trueProb}\n\nSolution: ${finalSol}`
376
+ const summaryContent = `Customer reported: ${reportedIssue}`
377
+
378
+ try {
379
+ const vectors = await generateEmbeddingBatch([semanticContent, summaryContent])
380
+ await Promise.all([
381
+ insertEmbedding(ticketId, 'semantic', 0, semanticContent, vectors[0], {
382
+ item_type: 'resolved_case',
383
+ security_level: 'internal',
384
+ kb_candidate: ticket.data?.ca_kb_candidate ?? false,
385
+ automation_potential: ticket.data?.ca_automation_potential ?? 'unknown',
386
+ }, accountId),
387
+ insertEmbedding(ticketId, 'summary', 0, summaryContent, vectors[1], {
388
+ item_type: 'resolved_case',
389
+ security_level: 'internal',
390
+ }, accountId),
391
+ ])
392
+ } catch (err) {
393
+ // Best-effort: don't fail ticket resolution if embedding fails
394
+ console.error(`embedResolvedCase failed for ticket ${ticketId}:`, err)
395
+ }
396
+ }
397
+
291
398
  // Generate embedding vector for a query string via OpenAI
292
399
  async function generateQueryEmbedding(text: string): Promise<number[]> {
293
400
  const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
@@ -0,0 +1,115 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+ import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
5
+ import { create } from './admin-data'
6
+
7
+ /**
8
+ * @module custom_support-redaction
9
+ * @layer custom/cortex
10
+ * @stability custom
11
+ *
12
+ * KB Redaction Analysis — drives the RedactionReview page.
13
+ * Runs content through the KB Redaction Agent and returns structured
14
+ * redaction suggestions + a generalised rewrite ready for human review.
15
+ *
16
+ * Action: POST ?action=analyze
17
+ * body: { ticket_id: string, content: string }
18
+ *
19
+ * Response: { analysis: RedactionAnalysis }
20
+ * RedactionAnalysis matches the shape expected by RedactionReview.tsx:
21
+ * {
22
+ * original_content: string
23
+ * redacted_content: string
24
+ * suggestions: RedactionSuggestion[]
25
+ * confidence_score: number
26
+ * processing_metadata: { model_used, temperature, tokens_consumed }
27
+ * }
28
+ *
29
+ * The agent message is saved in an internal analysis thread on the ticket
30
+ * (thread_purpose: 'redaction_analysis') as an audit trail.
31
+ */
32
+
33
+ async function handleAnalyze(ctx: any, body: any) {
34
+ const { ticket_id, content } = body
35
+ if (!ticket_id) throw new Error('ticket_id is required')
36
+ if (!content) throw new Error('content is required')
37
+
38
+ const [threadTypeId, agentId, promptConfigId] = await Promise.all([
39
+ resolveTypeId('thread', 'thread'),
40
+ resolveAgentId('KB Redaction Agent'),
41
+ resolvePromptConfigId('kb_generator'),
42
+ ])
43
+
44
+ // Reuse existing redaction thread if one exists (idempotent re-analysis)
45
+ const { data: existing } = await adminDb
46
+ .from('threads')
47
+ .select('id')
48
+ .eq('target_type', 'items')
49
+ .eq('target_id', ticket_id)
50
+ .eq('visibility', 'internal')
51
+ .eq('data->>thread_purpose', 'redaction_analysis')
52
+ .maybeSingle()
53
+
54
+ let threadId = existing?.id
55
+
56
+ if (!threadId) {
57
+ const thread = await create(ctx, {
58
+ entity: 'threads',
59
+ type_id: threadTypeId,
60
+ target_type: 'items',
61
+ target_id: ticket_id,
62
+ visibility: 'internal',
63
+ status: 'active',
64
+ data: {
65
+ thread_purpose: 'redaction_analysis',
66
+ agent_id: agentId,
67
+ prompt_config_id: promptConfigId,
68
+ }
69
+ })
70
+ if (!thread?.id) throw new Error('Failed to create redaction thread')
71
+ threadId = thread.id
72
+ }
73
+
74
+ // Run the KB Redaction Agent — returns structured JSON in message content
75
+ const agentMsg = await runAgent(threadId, content, ctx)
76
+
77
+ // Parse JSON from agent response
78
+ let analysis: any = null
79
+ try {
80
+ const raw = agentMsg?.content || '{}'
81
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
82
+ analysis = JSON.parse(cleaned)
83
+ } catch {
84
+ // If the agent didn't return valid JSON, return a minimal analysis
85
+ // so the UI can still proceed with the original content unredacted
86
+ analysis = {
87
+ original_content: content,
88
+ redacted_content: content,
89
+ suggestions: [],
90
+ confidence_score: 0.0,
91
+ processing_metadata: {
92
+ model_used: 'unknown',
93
+ temperature: 0.1,
94
+ tokens_consumed: 0,
95
+ error: 'Agent response was not valid JSON',
96
+ }
97
+ }
98
+ }
99
+
100
+ // Ensure required fields are present for RedactionReview.tsx
101
+ if (!analysis.original_content) analysis.original_content = content
102
+ if (!analysis.suggestions) analysis.suggestions = []
103
+ if (typeof analysis.confidence_score !== 'number') analysis.confidence_score = 0.5
104
+ if (!analysis.processing_metadata) analysis.processing_metadata = { model_used: 'unknown', temperature: 0.1, tokens_consumed: 0 }
105
+
106
+ return { analysis, analysisThreadId: threadId }
107
+ }
108
+
109
+ export const handler = createHandler(async (ctx, body) => {
110
+ const action = (ctx as any).query?.action
111
+ switch (action) {
112
+ case 'analyze': return handleAnalyze(ctx, body)
113
+ default: throw new Error(`Unknown action: ${action}. Use 'analyze'.`)
114
+ }
115
+ })
@@ -0,0 +1,104 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+ import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
5
+ import { create } from './admin-data'
6
+
7
+ /**
8
+ * @module custom_support-solution
9
+ * @layer custom/cortex
10
+ * @stability custom
11
+ *
12
+ * Solution AI — internal AI collaborator for support agents working a case.
13
+ * Powered by core's runAgent() with the 'solution_ai' prompt config.
14
+ * Never visible to the customer (all messages are visibility='internal').
15
+ *
16
+ * Two actions:
17
+ * POST ?action=start — ensures an internal AI thread exists for the ticket,
18
+ * sends the first message, returns thread + agent message.
19
+ * POST ?action=message — sends a follow-up message on the existing AI thread.
20
+ *
21
+ * Response: { threadId, agentMessageId, content }
22
+ *
23
+ * The AI thread is distinct from the regular internal thread: it is tagged with
24
+ * data.thread_purpose = 'solution_ai' so the UI can find it deterministically.
25
+ */
26
+
27
+ // ─── ACTION: START ─────────────────────────────────────────────────────────────
28
+
29
+ async function handleStart(ctx: any, body: any) {
30
+ const { ticket_id, message } = body
31
+ if (!ticket_id) throw new Error('ticket_id is required')
32
+ if (!message) throw new Error('message is required')
33
+
34
+ const [threadTypeId, solutionAgentId, promptConfigId] = await Promise.all([
35
+ resolveTypeId('thread', 'thread'),
36
+ resolveAgentId('Solution AI Agent'),
37
+ resolvePromptConfigId('solution_ai'),
38
+ ])
39
+
40
+ // Find existing solution AI thread for this ticket
41
+ const { data: existing } = await adminDb
42
+ .from('threads')
43
+ .select('id')
44
+ .eq('target_type', 'items')
45
+ .eq('target_id', ticket_id)
46
+ .eq('visibility', 'internal')
47
+ .eq('data->>thread_purpose', 'solution_ai')
48
+ .maybeSingle()
49
+
50
+ let threadId = existing?.id
51
+
52
+ if (!threadId) {
53
+ const thread = await create(ctx, {
54
+ entity: 'threads',
55
+ type_id: threadTypeId,
56
+ target_type: 'items',
57
+ target_id: ticket_id,
58
+ visibility: 'internal',
59
+ status: 'active',
60
+ data: {
61
+ thread_purpose: 'solution_ai',
62
+ agent_id: solutionAgentId,
63
+ prompt_config_id: promptConfigId,
64
+ }
65
+ })
66
+ if (!thread?.id) throw new Error('Failed to create solution AI thread')
67
+ threadId = thread.id
68
+ }
69
+
70
+ const agentMsg = await runAgent(threadId, message, ctx)
71
+
72
+ return {
73
+ threadId,
74
+ agentMessageId: agentMsg?.id,
75
+ content: agentMsg?.content || '',
76
+ }
77
+ }
78
+
79
+ // ─── ACTION: MESSAGE ───────────────────────────────────────────────────────────
80
+
81
+ async function handleMessage(ctx: any, body: any) {
82
+ const { thread_id, message } = body
83
+ if (!thread_id) throw new Error('thread_id is required')
84
+ if (!message) throw new Error('message is required')
85
+
86
+ const agentMsg = await runAgent(thread_id, message, ctx)
87
+
88
+ return {
89
+ threadId: thread_id,
90
+ agentMessageId: agentMsg?.id,
91
+ content: agentMsg?.content || '',
92
+ }
93
+ }
94
+
95
+ // ─── HANDLER ──────────────────────────────────────────────────────────────────
96
+
97
+ export const handler = createHandler(async (ctx, body) => {
98
+ const action = (ctx as any).query?.action
99
+ switch (action) {
100
+ case 'start': return handleStart(ctx, body)
101
+ case 'message': return handleMessage(ctx, body)
102
+ default: throw new Error(`Unknown action: ${action}. Use 'start' or 'message'.`)
103
+ }
104
+ })
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "Cortex",
3
3
  "slug": "cortex",
4
4
  "description": "Unified workspace for CRM, Support, Community, and Knowledge Base",
5
- "version": "0.2.12",
5
+ "version": "0.2.22",
6
6
  "app_type": "full",
7
7
  "route_prefix": "/cortex",
8
8
  "required_roles": ["support", "support_admin"],
@@ -19,6 +19,7 @@
19
19
  "/crm/activity",
20
20
  "/support",
21
21
  "/support/:id",
22
+ "/support/:id/kb-review",
22
23
  "/community",
23
24
  "/kb",
24
25
  "/kb/editor",
@@ -90,10 +91,13 @@
90
91
  "entry_point": "./index.tsx",
91
92
  "sidebar_component": "./components/CortexSidebar.tsx",
92
93
  "seed": [
93
- { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
94
- { "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
95
- { "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" },
96
- { "file": "seed/type_permissions.json", "table": "types", "conflict": "kind,slug", "inject_app_id": false, "permissions_patch": true }
94
+ { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
95
+ { "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
96
+ { "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" },
97
+ { "file": "seed/ai-agents.json", "table": "ai_agents", "conflict": "app_id,name", "inject_app_id": true },
98
+ { "file": "seed/prompt-configs.json", "table": "prompt_configs","conflict": "app_id,slug", "inject_app_id": true },
99
+ { "file": "seed/pipelines.json", "table": "pipelines", "conflict": "app_id,name", "inject_app_id": true },
100
+ { "file": "seed/type_permissions.json", "table": "types", "conflict": "kind,slug", "inject_app_id": false, "permissions_patch": true }
97
101
  ],
98
102
  "registration": {
99
103
  "enabled": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "private": false,
5
5
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
6
6
  "keywords": [
@@ -1,5 +1,6 @@
1
1
  import { useEffect, 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
5
6
  import { Badge } from '@core/components/ui/badge'
@@ -81,6 +82,7 @@ function PeopleTab({ accountId }: { accountId: string }) {
81
82
 
82
83
  function ItemsTab({ accountId, typeSlug, emptyText }: { accountId: string; typeSlug: string; emptyText: string }) {
83
84
  const navigate = useNavigate()
85
+ const appPath = useAppPath()
84
86
  const [items, setItems] = useState<Item[]>([])
85
87
  const [loading, setLoading] = useState(true)
86
88
  useEffect(() => {
@@ -94,7 +96,7 @@ function ItemsTab({ accountId, typeSlug, emptyText }: { accountId: string; typeS
94
96
  {items.map(item => (
95
97
  <div key={item.id}
96
98
  className="flex items-center gap-3 px-4 py-3 hover:bg-accent/50 cursor-pointer transition-colors"
97
- onClick={() => typeSlug === 'support_ticket' ? navigate(`/cortex/support/${item.id}`) : typeSlug === 'deal' ? navigate(`/cortex/crm/deals/${item.id}`) : undefined}
99
+ onClick={() => typeSlug === 'support_ticket' ? navigate(appPath(`/support/${item.id}`)) : typeSlug === 'deal' ? navigate(appPath(`/crm/deals/${item.id}`)) : undefined}
98
100
  >
99
101
  <div className="flex-1 min-w-0">
100
102
  <p className="text-sm font-medium truncate">{item.title}</p>
@@ -330,6 +332,7 @@ function FunnelTab({ account }: { account: Account }) {
330
332
  export default function AccountDetailPage() {
331
333
  const { id } = useParams<{ id: string }>()
332
334
  const navigate = useNavigate()
335
+ const appPath = useAppPath()
333
336
  const [account, setAccount] = useState<Account | null>(null)
334
337
  const [loading, setLoading] = useState(true)
335
338
 
@@ -345,7 +348,7 @@ export default function AccountDetailPage() {
345
348
  return (
346
349
  <div className="flex flex-col h-full">
347
350
  <div className="px-6 py-4 border-b border-border shrink-0">
348
- <Button variant="ghost" size="sm" className="mb-2 -ml-2 gap-1 text-muted-foreground" onClick={() => navigate('/cortex/crm/accounts')}>
351
+ <Button variant="ghost" size="sm" className="mb-2 -ml-2 gap-1 text-muted-foreground" onClick={() => navigate(appPath('/crm/accounts'))}>
349
352
  <ArrowLeft className="h-3.5 w-3.5" /> Accounts
350
353
  </Button>
351
354
  <div className="flex items-center gap-3">
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } 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 { Input } from '@core/components/ui/input'
5
6
  import { Badge } from '@core/components/ui/badge'
@@ -26,6 +27,7 @@ type Filter = 'all'
26
27
 
27
28
  export default function AccountsPage() {
28
29
  const navigate = useNavigate()
30
+ const appPath = useAppPath()
29
31
  const [accounts, setAccounts] = useState<Account[]>([])
30
32
  const [loading, setLoading] = useState(true)
31
33
  const [search, setSearch] = useState('')
@@ -91,7 +93,7 @@ export default function AccountsPage() {
91
93
  {filtered.map(account => (
92
94
  <tr
93
95
  key={account.id}
94
- onClick={() => navigate(`/cortex/crm/accounts/${account.id}`)}
96
+ onClick={() => navigate(appPath(`/crm/accounts/${account.id}`))}
95
97
  className="hover:bg-accent/50 cursor-pointer transition-colors"
96
98
  >
97
99
  <td className="px-5 py-3 font-medium">
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { useNavigate, useParams } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
 
5
6
  const STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost']
@@ -19,6 +20,7 @@ const EMPTY: DealForm = { title: '', stage: 'prospecting', value: '', close_date
19
20
  export default function DealDetailPage() {
20
21
  const { id } = useParams<{ id: string }>()
21
22
  const navigate = useNavigate()
23
+ const appPath = useAppPath()
22
24
  const isNew = !id || id === 'new'
23
25
 
24
26
  const [form, setForm] = useState<DealForm>(EMPTY)
@@ -82,7 +84,7 @@ export default function DealDetailPage() {
82
84
  const handleDelete = async () => {
83
85
  if (!confirm('Delete this deal?')) return
84
86
  await apiFetch(`/api/admin-data?action=delete&entity=items&id=${id}`, { method: 'POST' })
85
- navigate('/cortex/crm/deals')
87
+ navigate(appPath('/crm/deals'))
86
88
  }
87
89
 
88
90
  const set = (field: keyof DealForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
@@ -93,7 +95,7 @@ export default function DealDetailPage() {
93
95
  return (
94
96
  <div className="p-6 max-w-2xl">
95
97
  <div className="flex items-center gap-3 mb-6">
96
- <button onClick={() => navigate('/cortex/crm/deals')} className="text-slate-400 hover:text-slate-700 text-sm">
98
+ <button onClick={() => navigate(appPath('/crm/deals'))} className="text-slate-400 hover:text-slate-700 text-sm">
97
99
  ← Deals
98
100
  </button>
99
101
  <h1 className="text-xl font-bold text-slate-900">{isNew ? 'New Deal' : 'Edit Deal'}</h1>
@@ -174,7 +176,7 @@ export default function DealDetailPage() {
174
176
  </button>
175
177
  ) : <div />}
176
178
  <div className="flex gap-3">
177
- <button onClick={() => navigate('/cortex/crm/deals')} className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">
179
+ <button onClick={() => navigate(appPath('/crm/deals'))} className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">
178
180
  Cancel
179
181
  </button>
180
182
  <button
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } 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
 
5
6
  interface Deal {
@@ -40,6 +41,7 @@ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) {
40
41
 
41
42
  export default function DealsPage() {
42
43
  const navigate = useNavigate()
44
+ const appPath = useAppPath()
43
45
  const [deals, setDeals] = useState<Deal[]>([])
44
46
  const [loading, setLoading] = useState(true)
45
47
  const [view, setView] = useState<'kanban' | 'list'>('kanban')
@@ -77,7 +79,7 @@ export default function DealsPage() {
77
79
  </button>
78
80
  </div>
79
81
  <button
80
- onClick={() => navigate('/cortex/crm/deals/new')}
82
+ onClick={() => navigate(appPath('/crm/deals/new'))}
81
83
  className="bg-blue-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
82
84
  >
83
85
  + New Deal
@@ -133,7 +135,7 @@ export default function DealsPage() {
133
135
  <tr>
134
136
  <td colSpan={5} className="px-5 py-12 text-center text-slate-400">
135
137
  No deals yet.{' '}
136
- <button onClick={() => navigate('/cortex/crm/deals/new')} className="text-blue-600 hover:underline">
138
+ <button onClick={() => navigate(appPath('/crm/deals/new'))} className="text-blue-600 hover:underline">
137
139
  Create one →
138
140
  </button>
139
141
  </td>