arkaos 3.77.0 → 4.0.0

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 (91) hide show
  1. package/VERSION +1 -1
  2. package/config/agent-allowlists/laravel.yaml +1 -0
  3. package/config/agent-allowlists/node.yaml +1 -0
  4. package/config/agent-allowlists/nuxt.yaml +1 -0
  5. package/config/agent-allowlists/python.yaml +1 -0
  6. package/core/agents/__pycache__/registry_gen.cpython-313.pyc +0 -0
  7. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  8. package/core/agents/registry_gen.py +6 -1
  9. package/core/agents/schema.py +4 -0
  10. package/core/cognition/__pycache__/reorganizer.cpython-313.pyc +0 -0
  11. package/core/cognition/reorganizer.py +37 -7
  12. package/core/governance/__pycache__/design_system_lint.cpython-313.pyc +0 -0
  13. package/core/governance/__pycache__/design_system_lint_cli.cpython-313.pyc +0 -0
  14. package/core/knowledge/__pycache__/agent_match.cpython-313.pyc +0 -0
  15. package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
  16. package/core/knowledge/__pycache__/ingest.cpython-313.pyc +0 -0
  17. package/core/knowledge/__pycache__/sources.cpython-313.pyc +0 -0
  18. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  19. package/core/knowledge/agent_match.py +114 -0
  20. package/core/knowledge/chunker.py +45 -0
  21. package/core/knowledge/ingest.py +156 -78
  22. package/core/knowledge/sources.py +138 -0
  23. package/core/knowledge/vector_store.py +52 -0
  24. package/core/squads/__pycache__/loader.cpython-313.pyc +0 -0
  25. package/core/squads/loader.py +25 -0
  26. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  27. package/core/sync/agent_provisioner.py +19 -8
  28. package/dashboard/app/components/KnowledgeSourcesList.vue +40 -13
  29. package/dashboard/app/pages/cognition.vue +9 -4
  30. package/dashboard/app/pages/knowledge/[id].vue +669 -0
  31. package/dashboard/app/pages/knowledge/index.vue +1281 -0
  32. package/dashboard/app/types/index.d.ts +1 -1
  33. package/departments/brand/agents/brand-director.yaml +2 -0
  34. package/departments/brand/agents/creative-director.md +4 -0
  35. package/departments/brand/agents/motion-designer.md +5 -1
  36. package/departments/brand/agents/ux-designer.yaml +26 -1
  37. package/departments/brand/agents/ux-researcher.yaml +73 -0
  38. package/departments/brand/agents/ux-strategist.yaml +72 -0
  39. package/departments/brand/agents/visual-designer.md +4 -0
  40. package/departments/brand/agents/visual-designer.yaml +11 -0
  41. package/departments/brand/references/uiux-knowledge-and-tools.md +136 -0
  42. package/departments/dev/agents/ai-engineering/ai-engineering-lead.yaml +76 -0
  43. package/departments/dev/agents/architect.yaml +9 -3
  44. package/departments/dev/agents/backend-core/laravel-eng.yaml +76 -0
  45. package/departments/dev/agents/backend-core/node-ts-eng.yaml +76 -0
  46. package/departments/dev/agents/backend-core/python-eng.yaml +76 -0
  47. package/departments/dev/agents/backend-dev.yaml +10 -4
  48. package/departments/dev/agents/data-platform/etl-eng.yaml +74 -0
  49. package/departments/dev/agents/dba.yaml +7 -3
  50. package/departments/dev/agents/frontend-dev.md +41 -11
  51. package/departments/dev/agents/frontend-dev.yaml +6 -0
  52. package/departments/dev/references/backend-knowledge-and-tools.md +70 -0
  53. package/departments/ecom/agents/retention-manager.yaml +13 -1
  54. package/departments/leadership/agents/culture-coach.yaml +20 -0
  55. package/departments/leadership/agents/hr-specialist.yaml +18 -0
  56. package/departments/leadership/agents/leadership-director.yaml +10 -0
  57. package/departments/org/agents/chief-of-staff.yaml +76 -0
  58. package/departments/org/agents/coo.yaml +11 -0
  59. package/departments/org/agents/okr-steward.yaml +71 -0
  60. package/departments/org/agents/org-designer.yaml +23 -0
  61. package/departments/org/skills/okr-cadence/SKILL.md +34 -0
  62. package/departments/org/skills/principles-audit/SKILL.md +36 -0
  63. package/departments/pm/agents/pm-director.yaml +21 -8
  64. package/departments/pm/agents/product-owner.yaml +24 -2
  65. package/departments/pm/agents/scrum-master.yaml +21 -0
  66. package/departments/pm/agents/strategic-pm.yaml +72 -0
  67. package/departments/pm/skills/discovery-plan/SKILL.md +7 -1
  68. package/departments/quality/agents/cqo.yaml +8 -0
  69. package/departments/saas/agents/cs-manager.yaml +19 -2
  70. package/departments/saas/agents/growth-engineer.yaml +14 -1
  71. package/departments/saas/agents/metrics-analyst.yaml +17 -1
  72. package/departments/saas/agents/revops-lead.yaml +73 -0
  73. package/departments/saas/skills/leaky-bucket/SKILL.md +28 -0
  74. package/departments/saas/skills/voc-loop/SKILL.md +29 -0
  75. package/departments/sales/agents/sales-director.yaml +9 -0
  76. package/departments/sales/agents/sdr.yaml +72 -0
  77. package/departments/strategy/agents/decision-quality.yaml +72 -0
  78. package/departments/strategy/agents/strategy-director.yaml +13 -0
  79. package/departments/strategy/skills/premortem/SKILL.md +33 -0
  80. package/installer/claude-plugins.js +32 -3
  81. package/installer/doctor.js +15 -0
  82. package/installer/frontend-tooling.js +150 -0
  83. package/installer/index.js +28 -0
  84. package/installer/keys.js +1 -0
  85. package/installer/update.js +35 -0
  86. package/knowledge/agents-registry-v2.json +1218 -78
  87. package/package.json +1 -1
  88. package/pyproject.toml +1 -1
  89. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  90. package/scripts/dashboard-api.py +376 -13
  91. package/dashboard/app/pages/knowledge.vue +0 -918
@@ -1,918 +0,0 @@
1
- <script setup lang="ts">
2
- import type { KnowledgeStats, KnowledgeSearchResult, IngestRequest, IngestResponse, IngestTask } from '~/types'
3
-
4
- const { fetchApi, apiBase } = useApi()
5
-
6
- const { data: stats, status, error, refresh } = await fetchApi<KnowledgeStats>('/api/knowledge/stats')
7
-
8
- const isIndexed = computed(() => (stats.value?.total_chunks ?? 0) > 0)
9
-
10
- // --- Ingest Form State ---
11
- const ingestUrl = ref('')
12
- const ingestFile = ref<File | null>(null)
13
- const ingestFileInputRef = ref<HTMLInputElement | null>(null)
14
- const isIngesting = ref(false)
15
- const ingestError = ref<string | null>(null)
16
- const isDragging = ref(false)
17
- const pasteText = ref('')
18
- const pasteTitle = ref('')
19
- // PR56 v2.73.0 — bulk URL ingest mode. Paste a list of URLs (one per
20
- // line) and the backend queues one job per source.
21
- const bulkUrls = ref('')
22
-
23
- const activeInputMode = ref<'url' | 'file' | 'text' | 'research' | 'bulk'>('url')
24
-
25
- const inputModes = [
26
- { label: 'URL', value: 'url' as const, icon: 'i-lucide-link' },
27
- { label: 'Bulk', value: 'bulk' as const, icon: 'i-lucide-list' },
28
- { label: 'File', value: 'file' as const, icon: 'i-lucide-upload' },
29
- { label: 'Text', value: 'text' as const, icon: 'i-lucide-type' },
30
- { label: 'Research', value: 'research' as const, icon: 'i-lucide-search' },
31
- ]
32
-
33
- const bulkUrlCount = computed(() =>
34
- bulkUrls.value
35
- .split('\n')
36
- .map((s) => s.trim())
37
- .filter((s) => s.length > 0).length
38
- )
39
-
40
- function handleDrop(e: DragEvent) {
41
- isDragging.value = false
42
- const file = e.dataTransfer?.files?.[0]
43
- if (file) {
44
- ingestFile.value = file
45
- ingestUrl.value = ''
46
- }
47
- }
48
-
49
- type SourceType = IngestRequest['type'] | null
50
-
51
- const detectedType = computed<SourceType>(() => {
52
- const url = ingestUrl.value.trim()
53
- if (url) {
54
- if (/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i.test(url)) return 'youtube'
55
- if (/\.pdf(\?.*)?$/i.test(url)) return 'pdf'
56
- if (/\.(mp3|wav|m4a|ogg|flac)(\?.*)?$/i.test(url)) return 'audio'
57
- if (/\.(md|mdx)(\?.*)?$/i.test(url)) return 'markdown'
58
- if (/^https?:\/\//i.test(url)) return 'web'
59
- }
60
- if (ingestFile.value) {
61
- const name = ingestFile.value.name.toLowerCase()
62
- if (name.endsWith('.pdf')) return 'pdf'
63
- if (/\.(mp3|wav|m4a|ogg|flac)$/.test(name)) return 'audio'
64
- if (/\.(md|mdx)$/.test(name)) return 'markdown'
65
- }
66
- return null
67
- })
68
-
69
- const typeColorMap: Record<string, 'error' | 'primary' | 'warning' | 'success' | 'neutral'> = {
70
- youtube: 'error',
71
- web: 'primary',
72
- pdf: 'warning',
73
- audio: 'success',
74
- markdown: 'neutral'
75
- }
76
-
77
- const typeIconMap: Record<string, string> = {
78
- youtube: 'i-lucide-youtube',
79
- web: 'i-lucide-globe',
80
- pdf: 'i-lucide-file-text',
81
- audio: 'i-lucide-headphones',
82
- markdown: 'i-lucide-file-code'
83
- }
84
-
85
- function handleFileSelect(event: Event) {
86
- const target = event.target as HTMLInputElement
87
- ingestFile.value = target.files?.[0] ?? null
88
- if (ingestFile.value) {
89
- ingestUrl.value = ''
90
- }
91
- }
92
-
93
- function clearFile() {
94
- ingestFile.value = null
95
- if (ingestFileInputRef.value) {
96
- ingestFileInputRef.value.value = ''
97
- }
98
- }
99
-
100
- const canIngest = computed(() => {
101
- if (activeInputMode.value === 'bulk') return bulkUrlCount.value > 0
102
- return detectedType.value !== null
103
- })
104
-
105
- // --- Active Ingestion Tracking via WebSocket ---
106
- const activeTask = ref<IngestTask | null>(null)
107
- let ws: WebSocket | null = null
108
-
109
- // Persist active task ID across page navigation
110
- const ACTIVE_TASK_KEY = 'arkaos_active_ingest_task'
111
-
112
- async function restoreActiveTask() {
113
- const savedId = localStorage.getItem(ACTIVE_TASK_KEY)
114
- if (!savedId) return
115
- try {
116
- const task = await $fetch<any>(`${apiBase}/api/tasks/${savedId}`)
117
- if (task && task.status && !['completed', 'failed', 'cancelled'].includes(task.status)) {
118
- activeTask.value = task
119
- isIngesting.value = true
120
- connectWebSocket()
121
- } else {
122
- localStorage.removeItem(ACTIVE_TASK_KEY)
123
- }
124
- } catch {
125
- localStorage.removeItem(ACTIVE_TASK_KEY)
126
- }
127
- }
128
-
129
- onMounted(() => {
130
- restoreActiveTask()
131
- })
132
-
133
- function connectWebSocket() {
134
- if (ws && ws.readyState === WebSocket.OPEN) return
135
-
136
- const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
137
- ws = new WebSocket(wsUrl)
138
-
139
- ws.onmessage = (event) => {
140
- try {
141
- const data = JSON.parse(event.data)
142
- const jobId = data.job_id || data.task_id
143
-
144
- // Update active task if it matches
145
- if (activeTask.value && jobId === activeTask.value.id) {
146
- if (data.type === 'job_progress' || data.type === 'task_progress') {
147
- activeTask.value.progress_percent = data.progress
148
- activeTask.value.progress_message = data.message
149
- activeTask.value.status = data.status
150
- } else if (data.type === 'job_complete' || data.type === 'task_complete') {
151
- activeTask.value.status = 'completed'
152
- activeTask.value.progress_percent = 100
153
- activeTask.value.output_data = { chunks_created: data.chunks_created }
154
- isIngesting.value = false
155
- localStorage.removeItem(ACTIVE_TASK_KEY)
156
- refresh()
157
- fetchJobs()
158
- } else if (data.type === 'job_failed' || data.type === 'task_failed') {
159
- activeTask.value.status = 'failed'
160
- activeTask.value.error = data.error
161
- isIngesting.value = false
162
- localStorage.removeItem(ACTIVE_TASK_KEY)
163
- }
164
- }
165
-
166
- // Always refresh jobs table on any job event
167
- if (data.type?.startsWith('job_')) {
168
- fetchJobs()
169
- }
170
- } catch {}
171
- }
172
-
173
- ws.onclose = () => {
174
- // Reconnect after 2s if still ingesting
175
- if (isIngesting.value) {
176
- setTimeout(connectWebSocket, 2000)
177
- }
178
- }
179
- }
180
-
181
- function disconnectWebSocket() {
182
- if (ws) {
183
- ws.close()
184
- ws = null
185
- }
186
- }
187
-
188
- onUnmounted(() => {
189
- disconnectWebSocket()
190
- })
191
-
192
- async function handleIngest() {
193
- if (
194
- !detectedType.value
195
- && activeInputMode.value !== 'text'
196
- && activeInputMode.value !== 'bulk'
197
- ) return
198
-
199
- ingestError.value = null
200
-
201
- try {
202
- // File upload — use multipart form
203
- if (activeInputMode.value === 'file' && ingestFile.value) {
204
- const formData = new FormData()
205
- formData.append('file', ingestFile.value)
206
- await $fetch(`${apiBase}/api/knowledge/upload-file`, {
207
- method: 'POST',
208
- body: formData,
209
- })
210
- }
211
- // Text paste — save to temp file via API
212
- else if (activeInputMode.value === 'text' && pasteText.value.length > 10) {
213
- await $fetch(`${apiBase}/api/knowledge/ingest`, {
214
- method: 'POST',
215
- body: { source: pasteText.value.slice(0, 100), type: 'markdown', text: pasteText.value, title: pasteTitle.value },
216
- })
217
- }
218
- // Bulk URL paste — one job per non-blank line, server caps at 50
219
- else if (activeInputMode.value === 'bulk' && bulkUrlCount.value > 0) {
220
- const sources = bulkUrls.value
221
- .split('\n')
222
- .map((s) => s.trim())
223
- .filter((s) => s.length > 0)
224
- await $fetch(`${apiBase}/api/knowledge/ingest-bulk`, {
225
- method: 'POST',
226
- body: { sources },
227
- })
228
- }
229
- // URL or Research — standard ingest
230
- else {
231
- const source = ingestUrl.value.trim()
232
- const type = detectedType.value
233
- if (!source || !type) return
234
- await $fetch(`${apiBase}/api/knowledge/ingest`, {
235
- method: 'POST',
236
- body: { source, type },
237
- })
238
- }
239
-
240
- // Clear form immediately
241
- ingestUrl.value = ''
242
- clearFile()
243
- pasteText.value = ''
244
- pasteTitle.value = ''
245
- bulkUrls.value = ''
246
-
247
- // Refresh jobs table + connect WebSocket
248
- fetchJobs()
249
- connectWebSocket()
250
- } catch (err) {
251
- ingestError.value = err instanceof Error ? err.message : 'Failed to queue job'
252
- }
253
- }
254
-
255
- function retryIngest() {
256
- activeTask.value = null
257
- ingestError.value = null
258
- }
259
-
260
- function dismissActiveTask() {
261
- activeTask.value = null
262
- ingestUrl.value = ''
263
- localStorage.removeItem(ACTIVE_TASK_KEY)
264
- clearFile()
265
- }
266
-
267
- // --- Jobs Table (SQLite) ---
268
- const jobs = ref<any[]>([])
269
- const jobsSummary = ref<any>({})
270
-
271
- async function fetchJobs() {
272
- try {
273
- const response = await $fetch<{ jobs: any[], summary: any }>(`${apiBase}/api/jobs`)
274
- jobs.value = response.jobs ?? []
275
- jobsSummary.value = response.summary ?? {}
276
- } catch {}
277
- }
278
-
279
- fetchJobs()
280
-
281
- function formatDate(dateStr: string | undefined) {
282
- if (!dateStr) return '-'
283
- try {
284
- return new Intl.DateTimeFormat('en-US', {
285
- month: 'short',
286
- day: 'numeric',
287
- hour: '2-digit',
288
- minute: '2-digit'
289
- }).format(new Date(dateStr))
290
- } catch {
291
- return dateStr
292
- }
293
- }
294
-
295
- // --- Search ---
296
- const searchQuery = ref('')
297
- const searchResults = ref<KnowledgeSearchResult[]>([])
298
- const searchTotal = ref(0)
299
- const searching = ref(false)
300
- const hasSearched = ref(false)
301
-
302
- async function handleSearch() {
303
- if (!searchQuery.value.trim()) {
304
- searchResults.value = []
305
- searchTotal.value = 0
306
- hasSearched.value = false
307
- return
308
- }
309
-
310
- searching.value = true
311
- hasSearched.value = true
312
- try {
313
- const { data } = await useFetch<{ results: KnowledgeSearchResult[], query: string, total: number }>(
314
- `${apiBase}/api/knowledge/search`,
315
- { params: { q: searchQuery.value } }
316
- )
317
- searchResults.value = data.value?.results ?? []
318
- searchTotal.value = data.value?.total ?? 0
319
- } finally {
320
- searching.value = false
321
- }
322
- }
323
-
324
- function formatScore(score: number): string {
325
- return `${(score * 100).toFixed(0)}%`
326
- }
327
-
328
- // PR73 v2.91.0 — `vec_available` is the canonical PR47-era flag from
329
- // the new VectorStore; `vss_available` is the legacy field name from
330
- // earlier sqlite-vss builds. Treat either as "active".
331
- const vectorSearchActive = computed(() =>
332
- Boolean(stats.value?.vec_available || stats.value?.vss_available),
333
- )
334
-
335
- // PR71 v2.88.0 — delete all chunks from a given source.
336
-
337
- const deletingSource = ref<string | null>(null)
338
-
339
- const confirmDialog = useConfirmDialog()
340
-
341
- async function askDeleteSource(source: string) {
342
- if (!source) return
343
- const ok = await confirmDialog({
344
- title: 'Delete every indexed chunk from this source?',
345
- description:
346
- `${source}\n\nRemoves the source from search results but does NOT `
347
- + 'delete the original file. You can re-ingest later if needed.',
348
- confirmLabel: 'Delete chunks',
349
- variant: 'danger',
350
- })
351
- if (!ok) return
352
- await deleteSource(source)
353
- }
354
-
355
- async function deleteSource(source: string) {
356
- deletingSource.value = source
357
- try {
358
- const res = await $fetch<{ deleted?: number, source?: string, error?: string }>(
359
- `${apiBase}/api/knowledge/sources`,
360
- { method: 'DELETE', query: { source } },
361
- )
362
- if (res.error) {
363
- toast.add({
364
- title: 'Delete failed',
365
- description: res.error,
366
- color: 'error',
367
- })
368
- return
369
- }
370
- const deleted = res.deleted ?? 0
371
- // Drop the matching rows from the in-memory list without a full re-fetch.
372
- searchResults.value = searchResults.value.filter((r) => r.source !== source)
373
- searchTotal.value = searchResults.value.length
374
- // Refresh stats so the chunk count in the header updates.
375
- if (typeof refresh === 'function') {
376
- await refresh()
377
- }
378
- toast.add({
379
- title: deleted > 0
380
- ? `Deleted ${deleted} chunk${deleted === 1 ? '' : 's'}`
381
- : 'Nothing to delete',
382
- description: source,
383
- color: 'success',
384
- })
385
- } catch (err) {
386
- toast.add({
387
- title: 'Delete failed',
388
- description: err instanceof Error ? err.message : 'unknown error',
389
- color: 'error',
390
- })
391
- } finally {
392
- deletingSource.value = null
393
- }
394
- }
395
-
396
- // PR71 — highlight the search query in the preview text.
397
- // Tolerates malformed regex (escapes special characters) and HTML-
398
- // escapes the input so v-html'd output is safe from XSS via DB rows.
399
- function highlightMatches(text: string, query: string): string {
400
- const safe = escapeHtml(text || '')
401
- const q = (query || '').trim()
402
- if (!q) return safe
403
- const pattern = new RegExp(`(${escapeRegex(q)})`, 'gi')
404
- return safe.replace(
405
- pattern,
406
- '<mark class="bg-primary/20 text-primary rounded px-0.5">$1</mark>',
407
- )
408
- }
409
-
410
- function escapeHtml(value: string): string {
411
- return value
412
- .replace(/&/g, '&amp;')
413
- .replace(/</g, '&lt;')
414
- .replace(/>/g, '&gt;')
415
- .replace(/"/g, '&quot;')
416
- .replace(/'/g, '&#39;')
417
- }
418
-
419
- function escapeRegex(value: string): string {
420
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
421
- }
422
- </script>
423
-
424
- <template>
425
- <UDashboardPanel id="knowledge">
426
- <template #header>
427
- <UDashboardNavbar title="Knowledge Base">
428
- <template #leading>
429
- <UDashboardSidebarCollapse />
430
- </template>
431
- <template #trailing>
432
- <UBadge
433
- v-if="stats?.vec_available !== undefined || stats?.vss_available !== undefined"
434
- :label="vectorSearchActive ? 'Vector Active' : 'Vector Off'"
435
- :color="vectorSearchActive ? 'success' : 'warning'"
436
- variant="subtle"
437
- />
438
- </template>
439
- </UDashboardNavbar>
440
- </template>
441
-
442
- <template #body>
443
- <!-- Loading -->
444
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
445
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
446
- </div>
447
-
448
- <!-- Error -->
449
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
450
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
451
- <p class="text-sm text-muted">Failed to load knowledge stats.</p>
452
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
453
- </div>
454
-
455
- <!-- Content -->
456
- <template v-else>
457
- <!-- Add Content Section -->
458
- <UCard>
459
- <fieldset class="space-y-5">
460
- <!-- Input Mode Tabs -->
461
- <div class="flex items-center gap-1 rounded-lg bg-muted/10 p-1 w-fit">
462
- <button
463
- v-for="mode in inputModes"
464
- :key="mode.value"
465
- class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
466
- :class="activeInputMode === mode.value ? 'bg-elevated text-highlighted shadow-sm' : 'text-muted hover:text-highlighted'"
467
- @click="activeInputMode = mode.value"
468
- >
469
- <UIcon :name="mode.icon" class="size-3.5" />
470
- {{ mode.label }}
471
- </button>
472
- </div>
473
-
474
- <!-- Mode: URL -->
475
- <div v-if="activeInputMode === 'url'" class="space-y-3">
476
- <UInput
477
- v-model="ingestUrl"
478
- placeholder="Paste a YouTube URL, web page, article, or research link..."
479
- icon="i-lucide-link"
480
- size="xl"
481
- class="w-full"
482
- :ui="{ base: 'text-base' }"
483
- @keydown.enter.prevent="canIngest && handleIngest()"
484
- />
485
- <div class="flex items-center gap-1.5">
486
- <UBadge label="YouTube" color="error" variant="outline" size="xs" />
487
- <UBadge label="Web" color="primary" variant="outline" size="xs" />
488
- <UBadge label="Articles" color="primary" variant="outline" size="xs" />
489
- <UBadge label="Docs" color="neutral" variant="outline" size="xs" />
490
- </div>
491
- </div>
492
-
493
- <!-- Mode: File Upload with Drag & Drop -->
494
- <div
495
- v-if="activeInputMode === 'file'"
496
- class="relative rounded-xl border-2 border-dashed transition-colors p-8 text-center"
497
- :class="isDragging ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
498
- @dragover.prevent="isDragging = true"
499
- @dragleave.prevent="isDragging = false"
500
- @drop.prevent="handleDrop"
501
- >
502
- <input
503
- ref="ingestFileInputRef"
504
- type="file"
505
- accept=".pdf,.mp3,.wav,.m4a,.ogg,.flac,.md,.mdx,.txt"
506
- class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
507
- @change="handleFileSelect"
508
- />
509
- <div v-if="!ingestFile">
510
- <UIcon name="i-lucide-cloud-upload" class="size-10 text-muted mx-auto mb-3" />
511
- <p class="text-sm font-medium text-highlighted">Drop files here or click to browse</p>
512
- <p class="text-xs text-muted mt-1">PDF, MP3, WAV, Markdown, TXT</p>
513
- </div>
514
- <div v-else class="flex items-center justify-center gap-3">
515
- <UIcon :name="typeIconMap[detectedType ?? ''] ?? 'i-lucide-file'" class="size-6 text-primary" />
516
- <div class="text-left">
517
- <p class="text-sm font-medium text-highlighted">{{ ingestFile.name }}</p>
518
- <p class="text-xs text-muted">{{ (ingestFile.size / 1024).toFixed(1) }} KB</p>
519
- </div>
520
- <UButton icon="i-lucide-x" variant="ghost" size="xs" @click.stop="clearFile" />
521
- </div>
522
- </div>
523
-
524
- <!-- Mode: Text / Paste -->
525
- <div v-if="activeInputMode === 'text'" class="space-y-3">
526
- <textarea
527
- v-model="pasteText"
528
- rows="6"
529
- placeholder="Paste or write text content here... Notes, excerpts, research findings, transcripts..."
530
- class="w-full rounded-lg border border-default bg-transparent px-4 py-3 text-sm text-highlighted placeholder:text-muted/50 focus:border-primary focus:outline-none resize-y"
531
- />
532
- <UInput
533
- v-model="pasteTitle"
534
- placeholder="Title (optional) — e.g., 'Meeting Notes Q3', 'Research: Growth Hacking'"
535
- icon="i-lucide-type"
536
- size="sm"
537
- class="w-full"
538
- />
539
- </div>
540
-
541
- <!-- Mode: Bulk URLs (PR56 v2.73.0) -->
542
- <div v-if="activeInputMode === 'bulk'" class="space-y-3">
543
- <UTextarea
544
- v-model="bulkUrls"
545
- placeholder="Paste one URL per line. Up to 50 sources per batch.&#10;&#10;https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
546
- :rows="8"
547
- size="lg"
548
- class="w-full font-mono text-sm"
549
- />
550
- <div class="flex items-center justify-between text-xs text-muted">
551
- <span>{{ bulkUrlCount }} source{{ bulkUrlCount === 1 ? '' : 's' }} detected</span>
552
- <span v-if="bulkUrlCount > 50" class="text-red-400">
553
- Over the 50-source cap — extras will be rejected.
554
- </span>
555
- </div>
556
- </div>
557
-
558
- <!-- Mode: Research -->
559
- <div v-if="activeInputMode === 'research'" class="space-y-3">
560
- <UInput
561
- v-model="ingestUrl"
562
- placeholder="Enter a topic or URL to research... e.g., 'Alex Hormozi business model'"
563
- icon="i-lucide-search"
564
- size="xl"
565
- class="w-full"
566
- :ui="{ base: 'text-base' }"
567
- @keydown.enter.prevent="canIngest && handleIngest()"
568
- />
569
- <p class="text-xs text-muted">ArkaOS will fetch the page, extract the content, and index it into your knowledge base.</p>
570
- </div>
571
-
572
- <!-- Action Row -->
573
- <div class="flex items-center justify-between gap-4">
574
- <div class="flex items-center gap-2">
575
- <template v-if="detectedType">
576
- <UIcon :name="typeIconMap[detectedType] ?? 'i-lucide-file'" class="size-4 text-primary" />
577
- <UBadge
578
- :label="detectedType.charAt(0).toUpperCase() + detectedType.slice(1)"
579
- :color="typeColorMap[detectedType] ?? 'neutral'"
580
- variant="subtle"
581
- size="sm"
582
- />
583
- </template>
584
- <span v-else-if="activeInputMode === 'text' && pasteText" class="text-xs text-muted">
585
- {{ pasteText.split(/\s+/).length }} words
586
- </span>
587
- </div>
588
-
589
- <UButton
590
- :label="
591
- activeInputMode === 'research' ? 'Research & Index'
592
- : activeInputMode === 'bulk' ? `Ingest ${bulkUrlCount} source${bulkUrlCount === 1 ? '' : 's'}`
593
- : 'Ingest'
594
- "
595
- icon="i-lucide-zap"
596
- size="md"
597
- :disabled="!canIngest && !(activeInputMode === 'text' && pasteText.length > 50)"
598
- :loading="false"
599
- @click="handleIngest"
600
- />
601
- </div>
602
-
603
- <!-- Error -->
604
- <div v-if="ingestError" class="rounded-md border border-red-500/20 bg-red-500/5 p-3" role="alert">
605
- <div class="flex items-center gap-2">
606
- <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
607
- <p class="text-sm text-red-400">{{ ingestError }}</p>
608
- </div>
609
- </div>
610
- </fieldset>
611
- </UCard>
612
-
613
- <!-- Active Ingestion Progress -->
614
- <div v-if="activeTask" class="mt-4 rounded-lg border border-default p-6">
615
- <div class="flex items-center justify-between gap-4 mb-4">
616
- <div class="flex items-center gap-2 min-w-0">
617
- <UIcon
618
- v-if="activeTask.status === 'queued' || activeTask.status === 'processing'"
619
- name="i-lucide-loader-2"
620
- class="size-5 shrink-0 animate-spin text-primary"
621
- />
622
- <UIcon
623
- v-else-if="activeTask.status === 'completed'"
624
- name="i-lucide-check-circle"
625
- class="size-5 shrink-0 text-green-500"
626
- />
627
- <UIcon
628
- v-else-if="activeTask.status === 'failed'"
629
- name="i-lucide-x-circle"
630
- class="size-5 shrink-0 text-red-500"
631
- />
632
- <span class="text-sm font-medium text-highlighted truncate">{{ activeTask.title }}</span>
633
- </div>
634
- <div class="flex items-center gap-2 shrink-0">
635
- <UBadge
636
- v-if="activeTask.source_type"
637
- :label="activeTask.source_type.charAt(0).toUpperCase() + activeTask.source_type.slice(1)"
638
- :color="typeColorMap[activeTask.source_type] ?? 'neutral'"
639
- variant="subtle"
640
- size="sm"
641
- />
642
- <UBadge
643
- :label="activeTask.status"
644
- :color="activeTask.status === 'completed' ? 'success' : activeTask.status === 'failed' ? 'error' : 'primary'"
645
- variant="subtle"
646
- size="sm"
647
- class="capitalize"
648
- />
649
- </div>
650
- </div>
651
-
652
- <!-- Progress Bar -->
653
- <div v-if="activeTask.status !== 'failed'" class="space-y-2">
654
- <UProgress :value="activeTask.progress_percent" :max="100" size="sm" />
655
- <div class="flex items-center justify-between">
656
- <p class="text-xs text-muted">{{ activeTask.progress_message }}</p>
657
- <span class="text-xs font-mono text-muted">{{ activeTask.progress_percent }}%</span>
658
- </div>
659
- </div>
660
-
661
- <!-- Completed -->
662
- <div v-if="activeTask.status === 'completed'" class="mt-3 rounded-md border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950">
663
- <div class="flex items-center gap-2">
664
- <UIcon name="i-lucide-check" class="size-4 text-green-600" />
665
- <p class="text-sm text-green-700 dark:text-green-300">
666
- Ingestion complete.
667
- <span v-if="activeTask.output_data?.chunks_created">
668
- {{ activeTask.output_data.chunks_created }} chunks created.
669
- </span>
670
- </p>
671
- </div>
672
- <div class="mt-2">
673
- <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
674
- </div>
675
- </div>
676
-
677
- <!-- Failed -->
678
- <div v-if="activeTask.status === 'failed'" class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950" role="alert">
679
- <div class="flex items-center gap-2">
680
- <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
681
- <p class="text-sm text-red-700 dark:text-red-300">
682
- {{ activeTask.error || 'Ingestion failed.' }}
683
- </p>
684
- </div>
685
- <div class="mt-2 flex gap-2">
686
- <UButton label="Retry" variant="outline" size="xs" icon="i-lucide-refresh-cw" @click="retryIngest" />
687
- <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
688
- </div>
689
- </div>
690
- </div>
691
-
692
- <!-- Jobs Queue Table -->
693
- <div v-if="jobs.length" class="mt-4">
694
- <div class="flex items-center justify-between mb-3">
695
- <h3 class="text-sm font-semibold text-muted uppercase tracking-wider">Job Queue</h3>
696
- <div class="flex items-center gap-3 text-xs text-muted">
697
- <span v-if="jobsSummary.active">{{ jobsSummary.active }} active</span>
698
- <span>{{ jobsSummary.completed ?? 0 }} completed</span>
699
- <span v-if="jobsSummary.total_chunks">{{ jobsSummary.total_chunks }} total chunks</span>
700
- </div>
701
- </div>
702
-
703
- <div class="rounded-lg border border-default overflow-hidden">
704
- <table class="w-full text-sm">
705
- <thead>
706
- <tr class="border-b border-default bg-elevated/30">
707
- <th class="text-left py-2.5 px-4 text-xs font-semibold text-muted">Source</th>
708
- <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-20">Type</th>
709
- <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-40">Status</th>
710
- <th class="text-right py-2.5 px-3 text-xs font-semibold text-muted w-20">Chunks</th>
711
- <th class="text-right py-2.5 px-4 text-xs font-semibold text-muted w-32">Time</th>
712
- </tr>
713
- </thead>
714
- <tbody>
715
- <tr
716
- v-for="job in jobs"
717
- :key="job.id"
718
- class="border-b border-default last:border-b-0 hover:bg-elevated/20 transition-colors"
719
- >
720
- <td class="py-2.5 px-4">
721
- <div class="flex items-center gap-2 min-w-0">
722
- <UIcon :name="typeIconMap[job.type] ?? 'i-lucide-file'" class="size-4 shrink-0 text-muted" />
723
- <span class="truncate text-highlighted">{{ job.title }}</span>
724
- </div>
725
- </td>
726
- <td class="py-2.5 px-3">
727
- <UBadge
728
- v-if="job.type"
729
- :label="job.type"
730
- :color="typeColorMap[job.type] ?? 'neutral'"
731
- variant="subtle"
732
- size="xs"
733
- />
734
- </td>
735
- <td class="py-2.5 px-3">
736
- <div class="flex items-center gap-2">
737
- <UIcon
738
- v-if="['queued','processing','downloading','transcribing','embedding'].includes(job.status)"
739
- name="i-lucide-loader-2"
740
- class="size-3.5 animate-spin text-primary shrink-0"
741
- />
742
- <UIcon v-else-if="job.status === 'completed'" name="i-lucide-check-circle" class="size-3.5 text-green-500 shrink-0" />
743
- <UIcon v-else-if="job.status === 'failed'" name="i-lucide-x-circle" class="size-3.5 text-red-500 shrink-0" />
744
- <div class="flex-1 min-w-0">
745
- <div v-if="['processing','downloading','transcribing','embedding'].includes(job.status)" class="space-y-1">
746
- <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
747
- <div class="h-1.5 rounded-full bg-primary transition-all" :style="{ width: `${job.progress}%` }" />
748
- </div>
749
- <p class="text-[10px] text-muted truncate">{{ job.message }}</p>
750
- </div>
751
- <span v-else class="text-xs" :class="job.status === 'completed' ? 'text-green-400' : job.status === 'failed' ? 'text-red-400' : 'text-muted'">
752
- {{ job.status }}
753
- </span>
754
- </div>
755
- </div>
756
- </td>
757
- <td class="py-2.5 px-3 text-right">
758
- <span v-if="job.chunks_created" class="text-xs font-mono">{{ job.chunks_created }}</span>
759
- <span v-else class="text-xs text-muted">—</span>
760
- </td>
761
- <td class="py-2.5 px-4 text-right text-xs text-muted">
762
- {{ formatDate(job.completed_at || job.created_at) }}
763
- </td>
764
- </tr>
765
- </tbody>
766
- </table>
767
- </div>
768
- </div>
769
-
770
- <!-- Stats Section -->
771
- <div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
772
- <div class="rounded-lg border border-default p-4 text-center">
773
- <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_chunks ?? 0 }}</p>
774
- <p class="text-xs text-muted">Total Chunks</p>
775
- </div>
776
- <div class="rounded-lg border border-default p-4 text-center">
777
- <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_files ?? 0 }}</p>
778
- <p class="text-xs text-muted">Total Files</p>
779
- </div>
780
- <div class="rounded-lg border border-default p-4 text-center">
781
- <UBadge
782
- :label="vectorSearchActive ? 'Active' : 'Unavailable'"
783
- :color="vectorSearchActive ? 'success' : 'warning'"
784
- variant="subtle"
785
- size="sm"
786
- />
787
- <p class="text-xs text-muted mt-1">Vector Search</p>
788
- <p
789
- v-if="!vectorSearchActive && stats?.vec_unavailable_reason"
790
- class="text-xs text-yellow-400 mt-2 text-left"
791
- :title="stats.vec_unavailable_reason"
792
- >
793
- {{ stats.vec_unavailable_reason }}
794
- </p>
795
- </div>
796
- </div>
797
-
798
- <!-- Not Indexed State -->
799
- <div v-if="!isIndexed" class="mt-8">
800
- <div class="rounded-lg border-2 border-dashed border-default p-8 text-center">
801
- <UIcon name="i-lucide-database" class="size-16 text-muted mx-auto" />
802
- <h3 class="mt-4 text-lg font-semibold text-highlighted">Knowledge base not indexed yet</h3>
803
- <p class="mt-2 text-sm text-muted max-w-lg mx-auto">
804
- Index your Obsidian vault to enable semantic search across your entire knowledge base.
805
- </p>
806
- <div class="mt-6 inline-block rounded-lg border border-default bg-elevated/50 px-6 py-4 text-left">
807
- <p class="text-xs text-muted mb-2">Run this command to index:</p>
808
- <code class="font-mono text-sm text-primary">npx arkaos index</code>
809
- </div>
810
- <p class="mt-4 text-xs text-muted max-w-md mx-auto">
811
- This indexes your markdown files into a local vector database for automatic context retrieval.
812
- The process runs locally and your data never leaves your machine.
813
- </p>
814
- </div>
815
- </div>
816
-
817
- <!-- Indexed State -->
818
- <template v-else>
819
- <!-- Knowledge Areas -->
820
- <div v-if="stats?.areas?.length" class="mt-6">
821
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Knowledge Areas</h3>
822
- <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
823
- <div
824
- v-for="area in stats.areas"
825
- :key="area.name"
826
- class="rounded-lg border border-default p-4"
827
- >
828
- <h4 class="font-medium text-highlighted">{{ area.name }}</h4>
829
- <div class="mt-2 flex gap-4 text-xs text-muted">
830
- <span>{{ area.chunks }} chunks</span>
831
- <span>{{ area.files }} files</span>
832
- </div>
833
- </div>
834
- </div>
835
- </div>
836
-
837
- <!-- Search -->
838
- <div class="mt-6 rounded-lg border border-default p-6">
839
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Search Knowledge</h3>
840
- <form class="flex gap-2" @submit.prevent="handleSearch">
841
- <UInput
842
- v-model="searchQuery"
843
- class="flex-1"
844
- icon="i-lucide-search"
845
- placeholder="Search the knowledge base..."
846
- aria-label="Search knowledge base"
847
- />
848
- <UButton
849
- type="submit"
850
- label="Search"
851
- :loading="searching"
852
- icon="i-lucide-search"
853
- />
854
- </form>
855
-
856
- <!-- Search Results -->
857
- <div v-if="searching" class="mt-4 flex items-center justify-center py-8">
858
- <UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-muted" />
859
- </div>
860
-
861
- <template v-else-if="hasSearched">
862
- <div v-if="searchResults.length" class="mt-4 space-y-3">
863
- <p class="text-xs text-muted">{{ searchTotal }} result{{ searchTotal !== 1 ? 's' : '' }} found</p>
864
- <div
865
- v-for="(result, idx) in searchResults"
866
- :key="result.id ?? idx"
867
- class="rounded-lg border border-default p-4"
868
- >
869
- <div class="mb-2 flex items-center justify-between gap-2">
870
- <div class="flex items-center gap-2 min-w-0">
871
- <UBadge v-if="result.area" :label="result.area" variant="subtle" size="sm" />
872
- <span v-if="result.heading" class="text-sm font-medium text-highlighted truncate">
873
- {{ result.heading }}
874
- </span>
875
- </div>
876
- <div class="flex items-center gap-2 shrink-0">
877
- <span class="text-xs text-muted whitespace-nowrap">
878
- Score: {{ formatScore(result.score) }}
879
- </span>
880
- <UButton
881
- v-if="result.source"
882
- :icon="deletingSource === result.source
883
- ? 'i-lucide-loader-2'
884
- : 'i-lucide-trash-2'"
885
- :loading="deletingSource === result.source"
886
- variant="ghost"
887
- color="error"
888
- size="xs"
889
- aria-label="Delete all chunks from this source"
890
- @click.stop="askDeleteSource(result.source)"
891
- />
892
- </div>
893
- </div>
894
- <p v-if="result.source" class="text-xs text-muted mb-1 truncate">
895
- <UIcon name="i-lucide-file-text" class="size-3 inline-block mr-1" />
896
- {{ result.source }}
897
- </p>
898
- <!-- PR71 v2.88.0 — highlight query matches in the preview -->
899
- <p class="text-sm text-muted line-clamp-3" v-html="highlightMatches(result.text || result.content, searchQuery)" />
900
- </div>
901
- </div>
902
-
903
- <div v-else class="mt-4 text-center text-sm text-muted py-6">
904
- <UIcon name="i-lucide-search-x" class="size-8 text-muted mx-auto mb-2" />
905
- <p>No results found for "{{ searchQuery }}".</p>
906
- </div>
907
- </template>
908
- </div>
909
-
910
- <!-- PR88c v3.25.0 — Indexed sources management -->
911
- <div class="mt-6">
912
- <KnowledgeSourcesList />
913
- </div>
914
- </template>
915
- </template>
916
- </template>
917
- </UDashboardPanel>
918
- </template>