arkaos 2.3.0 → 2.3.2

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 (64) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/conclave/SKILL.md +194 -0
  3. package/arka/skills/human-writing/SKILL.md +143 -0
  4. package/config/agent-memory-template.md +28 -0
  5. package/config/disc-profiles.json +108 -0
  6. package/config/disc-team-validator.sh +94 -0
  7. package/config/gotchas-fixes.json +148 -0
  8. package/config/profile-template.json +12 -0
  9. package/config/providers-registry.json +56 -0
  10. package/config/settings-template.json +42 -0
  11. package/config/standards/communication.md +64 -0
  12. package/config/standards/orchestration.md +91 -0
  13. package/config/statusline-v2.sh +101 -0
  14. package/config/statusline.sh +139 -0
  15. package/config/system-prompt.sh +190 -0
  16. package/dashboard/LICENSE +21 -0
  17. package/dashboard/README.md +64 -0
  18. package/dashboard/app/app.config.ts +8 -0
  19. package/dashboard/app/app.vue +42 -0
  20. package/dashboard/app/assets/css/main.css +18 -0
  21. package/dashboard/app/composables/useApi.ts +8 -0
  22. package/dashboard/app/composables/useDashboard.ts +19 -0
  23. package/dashboard/app/error.vue +24 -0
  24. package/dashboard/app/layouts/default.vue +114 -0
  25. package/dashboard/app/pages/agents/[id].vue +506 -0
  26. package/dashboard/app/pages/agents/index.vue +225 -0
  27. package/dashboard/app/pages/budget.vue +132 -0
  28. package/dashboard/app/pages/commands.vue +180 -0
  29. package/dashboard/app/pages/health.vue +98 -0
  30. package/dashboard/app/pages/index.vue +126 -0
  31. package/dashboard/app/pages/knowledge.vue +729 -0
  32. package/dashboard/app/pages/personas.vue +597 -0
  33. package/dashboard/app/pages/settings.vue +146 -0
  34. package/dashboard/app/pages/tasks.vue +203 -0
  35. package/dashboard/app/types/index.d.ts +181 -0
  36. package/dashboard/app/utils/index.ts +7 -0
  37. package/dashboard/nuxt.config.ts +39 -0
  38. package/dashboard/package.json +37 -0
  39. package/dashboard/pnpm-workspace.yaml +7 -0
  40. package/dashboard/tsconfig.json +10 -0
  41. package/knowledge/INDEX.md +34 -0
  42. package/knowledge/agents-registry.json +254 -0
  43. package/knowledge/channels-config.json +6 -0
  44. package/knowledge/commands-keywords.json +466 -0
  45. package/knowledge/commands-registry.json +2791 -0
  46. package/knowledge/commands-registry.json.bak +2791 -0
  47. package/knowledge/ecosystems.json +7 -0
  48. package/knowledge/obsidian-config.json +112 -0
  49. package/package.json +10 -6
  50. package/pyproject.toml +1 -1
  51. package/scripts/check-version.js +13 -0
  52. package/scripts/dashboard-api.py +636 -0
  53. package/scripts/knowledge-index.py +113 -0
  54. package/scripts/skill_validator.py +217 -0
  55. package/scripts/start-dashboard.sh +103 -0
  56. package/scripts/synapse-bridge.py +199 -0
  57. package/scripts/tools/brand_voice_analyzer.py +192 -0
  58. package/scripts/tools/dcf_calculator.py +168 -0
  59. package/scripts/tools/headline_scorer.py +215 -0
  60. package/scripts/tools/okr_cascade.py +207 -0
  61. package/scripts/tools/rice_prioritizer.py +230 -0
  62. package/scripts/tools/saas_metrics.py +234 -0
  63. package/scripts/tools/seo_checker.py +197 -0
  64. package/scripts/tools/tech_debt_analyzer.py +206 -0
@@ -0,0 +1,729 @@
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
+
20
+ const activeInputMode = ref<'url' | 'file' | 'text' | 'research'>('url')
21
+
22
+ const inputModes = [
23
+ { label: 'URL', value: 'url' as const, icon: 'i-lucide-link' },
24
+ { label: 'File', value: 'file' as const, icon: 'i-lucide-upload' },
25
+ { label: 'Text', value: 'text' as const, icon: 'i-lucide-type' },
26
+ { label: 'Research', value: 'research' as const, icon: 'i-lucide-search' },
27
+ ]
28
+
29
+ function handleDrop(e: DragEvent) {
30
+ isDragging.value = false
31
+ const file = e.dataTransfer?.files?.[0]
32
+ if (file) {
33
+ ingestFile.value = file
34
+ ingestUrl.value = ''
35
+ }
36
+ }
37
+
38
+ type SourceType = IngestRequest['type'] | null
39
+
40
+ const detectedType = computed<SourceType>(() => {
41
+ const url = ingestUrl.value.trim()
42
+ if (url) {
43
+ if (/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i.test(url)) return 'youtube'
44
+ if (/\.pdf(\?.*)?$/i.test(url)) return 'pdf'
45
+ if (/\.(mp3|wav|m4a|ogg|flac)(\?.*)?$/i.test(url)) return 'audio'
46
+ if (/\.(md|mdx)(\?.*)?$/i.test(url)) return 'markdown'
47
+ if (/^https?:\/\//i.test(url)) return 'web'
48
+ }
49
+ if (ingestFile.value) {
50
+ const name = ingestFile.value.name.toLowerCase()
51
+ if (name.endsWith('.pdf')) return 'pdf'
52
+ if (/\.(mp3|wav|m4a|ogg|flac)$/.test(name)) return 'audio'
53
+ if (/\.(md|mdx)$/.test(name)) return 'markdown'
54
+ }
55
+ return null
56
+ })
57
+
58
+ const typeColorMap: Record<string, 'error' | 'primary' | 'warning' | 'success' | 'neutral'> = {
59
+ youtube: 'error',
60
+ web: 'primary',
61
+ pdf: 'warning',
62
+ audio: 'success',
63
+ markdown: 'neutral'
64
+ }
65
+
66
+ const typeIconMap: Record<string, string> = {
67
+ youtube: 'i-lucide-youtube',
68
+ web: 'i-lucide-globe',
69
+ pdf: 'i-lucide-file-text',
70
+ audio: 'i-lucide-headphones',
71
+ markdown: 'i-lucide-file-code'
72
+ }
73
+
74
+ function handleFileSelect(event: Event) {
75
+ const target = event.target as HTMLInputElement
76
+ ingestFile.value = target.files?.[0] ?? null
77
+ if (ingestFile.value) {
78
+ ingestUrl.value = ''
79
+ }
80
+ }
81
+
82
+ function clearFile() {
83
+ ingestFile.value = null
84
+ if (ingestFileInputRef.value) {
85
+ ingestFileInputRef.value.value = ''
86
+ }
87
+ }
88
+
89
+ const canIngest = computed(() => {
90
+ return detectedType.value !== null
91
+ })
92
+
93
+ // --- Active Ingestion Tracking via WebSocket ---
94
+ const activeTask = ref<IngestTask | null>(null)
95
+ let ws: WebSocket | null = null
96
+
97
+ // Persist active task ID across page navigation
98
+ const ACTIVE_TASK_KEY = 'arkaos_active_ingest_task'
99
+
100
+ async function restoreActiveTask() {
101
+ const savedId = localStorage.getItem(ACTIVE_TASK_KEY)
102
+ if (!savedId) return
103
+ try {
104
+ const task = await $fetch<any>(`${apiBase}/api/tasks/${savedId}`)
105
+ if (task && task.status && !['completed', 'failed', 'cancelled'].includes(task.status)) {
106
+ activeTask.value = task
107
+ isIngesting.value = true
108
+ connectWebSocket()
109
+ } else {
110
+ localStorage.removeItem(ACTIVE_TASK_KEY)
111
+ }
112
+ } catch {
113
+ localStorage.removeItem(ACTIVE_TASK_KEY)
114
+ }
115
+ }
116
+
117
+ onMounted(() => {
118
+ restoreActiveTask()
119
+ })
120
+
121
+ function connectWebSocket() {
122
+ if (ws && ws.readyState === WebSocket.OPEN) return
123
+
124
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
125
+ ws = new WebSocket(wsUrl)
126
+
127
+ ws.onmessage = (event) => {
128
+ try {
129
+ const data = JSON.parse(event.data)
130
+ const jobId = data.job_id || data.task_id
131
+
132
+ // Update active task if it matches
133
+ if (activeTask.value && jobId === activeTask.value.id) {
134
+ if (data.type === 'job_progress' || data.type === 'task_progress') {
135
+ activeTask.value.progress_percent = data.progress
136
+ activeTask.value.progress_message = data.message
137
+ activeTask.value.status = data.status
138
+ } else if (data.type === 'job_complete' || data.type === 'task_complete') {
139
+ activeTask.value.status = 'completed'
140
+ activeTask.value.progress_percent = 100
141
+ activeTask.value.output_data = { chunks_created: data.chunks_created }
142
+ isIngesting.value = false
143
+ localStorage.removeItem(ACTIVE_TASK_KEY)
144
+ refresh()
145
+ fetchJobs()
146
+ } else if (data.type === 'job_failed' || data.type === 'task_failed') {
147
+ activeTask.value.status = 'failed'
148
+ activeTask.value.error = data.error
149
+ isIngesting.value = false
150
+ localStorage.removeItem(ACTIVE_TASK_KEY)
151
+ }
152
+ }
153
+
154
+ // Always refresh jobs table on any job event
155
+ if (data.type?.startsWith('job_')) {
156
+ fetchJobs()
157
+ }
158
+ } catch {}
159
+ }
160
+
161
+ ws.onclose = () => {
162
+ // Reconnect after 2s if still ingesting
163
+ if (isIngesting.value) {
164
+ setTimeout(connectWebSocket, 2000)
165
+ }
166
+ }
167
+ }
168
+
169
+ function disconnectWebSocket() {
170
+ if (ws) {
171
+ ws.close()
172
+ ws = null
173
+ }
174
+ }
175
+
176
+ onUnmounted(() => {
177
+ disconnectWebSocket()
178
+ })
179
+
180
+ async function handleIngest() {
181
+ if (!detectedType.value) return
182
+
183
+ ingestError.value = null
184
+ const source = ingestUrl.value.trim() || ingestFile.value?.name || ''
185
+ const type = detectedType.value
186
+
187
+ // Clear form immediately so user can submit more
188
+ ingestUrl.value = ''
189
+ clearFile()
190
+ pasteText.value = ''
191
+ pasteTitle.value = ''
192
+
193
+ try {
194
+ await $fetch<IngestResponse>(`${apiBase}/api/knowledge/ingest`, {
195
+ method: 'POST',
196
+ body: { source, type } satisfies IngestRequest,
197
+ })
198
+
199
+ // Refresh jobs table + connect WebSocket for live updates
200
+ fetchJobs()
201
+ connectWebSocket()
202
+ } catch (err) {
203
+ ingestError.value = err instanceof Error ? err.message : 'Failed to queue job'
204
+ }
205
+ }
206
+
207
+ function retryIngest() {
208
+ activeTask.value = null
209
+ ingestError.value = null
210
+ }
211
+
212
+ function dismissActiveTask() {
213
+ activeTask.value = null
214
+ ingestUrl.value = ''
215
+ localStorage.removeItem(ACTIVE_TASK_KEY)
216
+ clearFile()
217
+ }
218
+
219
+ // --- Jobs Table (SQLite) ---
220
+ const jobs = ref<any[]>([])
221
+ const jobsSummary = ref<any>({})
222
+
223
+ async function fetchJobs() {
224
+ try {
225
+ const response = await $fetch<{ jobs: any[], summary: any }>(`${apiBase}/api/jobs`)
226
+ jobs.value = response.jobs ?? []
227
+ jobsSummary.value = response.summary ?? {}
228
+ } catch {}
229
+ }
230
+
231
+ fetchJobs()
232
+
233
+ function formatDate(dateStr: string | undefined) {
234
+ if (!dateStr) return '-'
235
+ try {
236
+ return new Intl.DateTimeFormat('en-US', {
237
+ month: 'short',
238
+ day: 'numeric',
239
+ hour: '2-digit',
240
+ minute: '2-digit'
241
+ }).format(new Date(dateStr))
242
+ } catch {
243
+ return dateStr
244
+ }
245
+ }
246
+
247
+ // --- Search ---
248
+ const searchQuery = ref('')
249
+ const searchResults = ref<KnowledgeSearchResult[]>([])
250
+ const searchTotal = ref(0)
251
+ const searching = ref(false)
252
+ const hasSearched = ref(false)
253
+
254
+ async function handleSearch() {
255
+ if (!searchQuery.value.trim()) {
256
+ searchResults.value = []
257
+ searchTotal.value = 0
258
+ hasSearched.value = false
259
+ return
260
+ }
261
+
262
+ searching.value = true
263
+ hasSearched.value = true
264
+ try {
265
+ const { data } = await useFetch<{ results: KnowledgeSearchResult[], query: string, total: number }>(
266
+ `${apiBase}/api/knowledge/search`,
267
+ { params: { q: searchQuery.value } }
268
+ )
269
+ searchResults.value = data.value?.results ?? []
270
+ searchTotal.value = data.value?.total ?? 0
271
+ } finally {
272
+ searching.value = false
273
+ }
274
+ }
275
+
276
+ function formatScore(score: number): string {
277
+ return `${(score * 100).toFixed(0)}%`
278
+ }
279
+ </script>
280
+
281
+ <template>
282
+ <UDashboardPanel id="knowledge">
283
+ <template #header>
284
+ <UDashboardNavbar title="Knowledge Base">
285
+ <template #leading>
286
+ <UDashboardSidebarCollapse />
287
+ </template>
288
+ <template #trailing>
289
+ <UBadge
290
+ v-if="stats?.vss_available !== undefined"
291
+ :label="stats.vss_available ? 'VSS Active' : 'VSS Unavailable'"
292
+ :color="stats.vss_available ? 'success' : 'neutral'"
293
+ variant="subtle"
294
+ />
295
+ </template>
296
+ </UDashboardNavbar>
297
+ </template>
298
+
299
+ <template #body>
300
+ <!-- Loading -->
301
+ <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
302
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
303
+ </div>
304
+
305
+ <!-- Error -->
306
+ <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
307
+ <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
308
+ <p class="text-sm text-muted">Failed to load knowledge stats.</p>
309
+ <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
310
+ </div>
311
+
312
+ <!-- Content -->
313
+ <template v-else>
314
+ <!-- Add Content Section -->
315
+ <UCard>
316
+ <fieldset class="space-y-5">
317
+ <!-- Input Mode Tabs -->
318
+ <div class="flex items-center gap-1 rounded-lg bg-muted/10 p-1 w-fit">
319
+ <button
320
+ v-for="mode in inputModes"
321
+ :key="mode.value"
322
+ class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
323
+ :class="activeInputMode === mode.value ? 'bg-elevated text-highlighted shadow-sm' : 'text-muted hover:text-highlighted'"
324
+ @click="activeInputMode = mode.value"
325
+ >
326
+ <UIcon :name="mode.icon" class="size-3.5" />
327
+ {{ mode.label }}
328
+ </button>
329
+ </div>
330
+
331
+ <!-- Mode: URL -->
332
+ <div v-if="activeInputMode === 'url'" class="space-y-3">
333
+ <UInput
334
+ v-model="ingestUrl"
335
+ placeholder="Paste a YouTube URL, web page, article, or research link..."
336
+ icon="i-lucide-link"
337
+ size="xl"
338
+ class="w-full"
339
+ :ui="{ base: 'text-base' }"
340
+ @keydown.enter.prevent="canIngest && handleIngest()"
341
+ />
342
+ <div class="flex items-center gap-1.5">
343
+ <UBadge label="YouTube" color="error" variant="outline" size="xs" />
344
+ <UBadge label="Web" color="primary" variant="outline" size="xs" />
345
+ <UBadge label="Articles" color="primary" variant="outline" size="xs" />
346
+ <UBadge label="Docs" color="neutral" variant="outline" size="xs" />
347
+ </div>
348
+ </div>
349
+
350
+ <!-- Mode: File Upload with Drag & Drop -->
351
+ <div
352
+ v-if="activeInputMode === 'file'"
353
+ class="relative rounded-xl border-2 border-dashed transition-colors p-8 text-center"
354
+ :class="isDragging ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
355
+ @dragover.prevent="isDragging = true"
356
+ @dragleave.prevent="isDragging = false"
357
+ @drop.prevent="handleDrop"
358
+ >
359
+ <input
360
+ ref="ingestFileInputRef"
361
+ type="file"
362
+ accept=".pdf,.mp3,.wav,.m4a,.ogg,.flac,.md,.mdx,.txt"
363
+ class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
364
+ @change="handleFileSelect"
365
+ />
366
+ <div v-if="!ingestFile">
367
+ <UIcon name="i-lucide-cloud-upload" class="size-10 text-muted mx-auto mb-3" />
368
+ <p class="text-sm font-medium text-highlighted">Drop files here or click to browse</p>
369
+ <p class="text-xs text-muted mt-1">PDF, MP3, WAV, Markdown, TXT</p>
370
+ </div>
371
+ <div v-else class="flex items-center justify-center gap-3">
372
+ <UIcon :name="typeIconMap[detectedType ?? ''] ?? 'i-lucide-file'" class="size-6 text-primary" />
373
+ <div class="text-left">
374
+ <p class="text-sm font-medium text-highlighted">{{ ingestFile.name }}</p>
375
+ <p class="text-xs text-muted">{{ (ingestFile.size / 1024).toFixed(1) }} KB</p>
376
+ </div>
377
+ <UButton icon="i-lucide-x" variant="ghost" size="xs" @click.stop="clearFile" />
378
+ </div>
379
+ </div>
380
+
381
+ <!-- Mode: Text / Paste -->
382
+ <div v-if="activeInputMode === 'text'" class="space-y-3">
383
+ <textarea
384
+ v-model="pasteText"
385
+ rows="6"
386
+ placeholder="Paste or write text content here... Notes, excerpts, research findings, transcripts..."
387
+ 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"
388
+ />
389
+ <UInput
390
+ v-model="pasteTitle"
391
+ placeholder="Title (optional) — e.g., 'Meeting Notes Q3', 'Research: Growth Hacking'"
392
+ icon="i-lucide-type"
393
+ size="sm"
394
+ class="w-full"
395
+ />
396
+ </div>
397
+
398
+ <!-- Mode: Research -->
399
+ <div v-if="activeInputMode === 'research'" class="space-y-3">
400
+ <UInput
401
+ v-model="ingestUrl"
402
+ placeholder="Enter a topic or URL to research... e.g., 'Alex Hormozi business model'"
403
+ icon="i-lucide-search"
404
+ size="xl"
405
+ class="w-full"
406
+ :ui="{ base: 'text-base' }"
407
+ @keydown.enter.prevent="canIngest && handleIngest()"
408
+ />
409
+ <p class="text-xs text-muted">ArkaOS will fetch the page, extract the content, and index it into your knowledge base.</p>
410
+ </div>
411
+
412
+ <!-- Action Row -->
413
+ <div class="flex items-center justify-between gap-4">
414
+ <div class="flex items-center gap-2">
415
+ <template v-if="detectedType">
416
+ <UIcon :name="typeIconMap[detectedType] ?? 'i-lucide-file'" class="size-4 text-primary" />
417
+ <UBadge
418
+ :label="detectedType.charAt(0).toUpperCase() + detectedType.slice(1)"
419
+ :color="typeColorMap[detectedType] ?? 'neutral'"
420
+ variant="subtle"
421
+ size="sm"
422
+ />
423
+ </template>
424
+ <span v-else-if="activeInputMode === 'text' && pasteText" class="text-xs text-muted">
425
+ {{ pasteText.split(/\s+/).length }} words
426
+ </span>
427
+ </div>
428
+
429
+ <UButton
430
+ :label="activeInputMode === 'research' ? 'Research & Index' : 'Ingest'"
431
+ icon="i-lucide-zap"
432
+ size="md"
433
+ :disabled="!canIngest && !(activeInputMode === 'text' && pasteText.length > 50)"
434
+ :loading="false"
435
+ @click="handleIngest"
436
+ />
437
+ </div>
438
+
439
+ <!-- Error -->
440
+ <div v-if="ingestError" class="rounded-md border border-red-500/20 bg-red-500/5 p-3" role="alert">
441
+ <div class="flex items-center gap-2">
442
+ <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
443
+ <p class="text-sm text-red-400">{{ ingestError }}</p>
444
+ </div>
445
+ </div>
446
+ </fieldset>
447
+ </UCard>
448
+
449
+ <!-- Active Ingestion Progress -->
450
+ <div v-if="activeTask" class="mt-4 rounded-lg border border-default p-6">
451
+ <div class="flex items-center justify-between gap-4 mb-4">
452
+ <div class="flex items-center gap-2 min-w-0">
453
+ <UIcon
454
+ v-if="activeTask.status === 'queued' || activeTask.status === 'processing'"
455
+ name="i-lucide-loader-2"
456
+ class="size-5 shrink-0 animate-spin text-primary"
457
+ />
458
+ <UIcon
459
+ v-else-if="activeTask.status === 'completed'"
460
+ name="i-lucide-check-circle"
461
+ class="size-5 shrink-0 text-green-500"
462
+ />
463
+ <UIcon
464
+ v-else-if="activeTask.status === 'failed'"
465
+ name="i-lucide-x-circle"
466
+ class="size-5 shrink-0 text-red-500"
467
+ />
468
+ <span class="text-sm font-medium text-highlighted truncate">{{ activeTask.title }}</span>
469
+ </div>
470
+ <div class="flex items-center gap-2 shrink-0">
471
+ <UBadge
472
+ v-if="activeTask.source_type"
473
+ :label="activeTask.source_type.charAt(0).toUpperCase() + activeTask.source_type.slice(1)"
474
+ :color="typeColorMap[activeTask.source_type] ?? 'neutral'"
475
+ variant="subtle"
476
+ size="sm"
477
+ />
478
+ <UBadge
479
+ :label="activeTask.status"
480
+ :color="activeTask.status === 'completed' ? 'success' : activeTask.status === 'failed' ? 'error' : 'primary'"
481
+ variant="subtle"
482
+ size="sm"
483
+ class="capitalize"
484
+ />
485
+ </div>
486
+ </div>
487
+
488
+ <!-- Progress Bar -->
489
+ <div v-if="activeTask.status !== 'failed'" class="space-y-2">
490
+ <UProgress :value="activeTask.progress_percent" :max="100" size="sm" />
491
+ <div class="flex items-center justify-between">
492
+ <p class="text-xs text-muted">{{ activeTask.progress_message }}</p>
493
+ <span class="text-xs font-mono text-muted">{{ activeTask.progress_percent }}%</span>
494
+ </div>
495
+ </div>
496
+
497
+ <!-- Completed -->
498
+ <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">
499
+ <div class="flex items-center gap-2">
500
+ <UIcon name="i-lucide-check" class="size-4 text-green-600" />
501
+ <p class="text-sm text-green-700 dark:text-green-300">
502
+ Ingestion complete.
503
+ <span v-if="activeTask.output_data?.chunks_created">
504
+ {{ activeTask.output_data.chunks_created }} chunks created.
505
+ </span>
506
+ </p>
507
+ </div>
508
+ <div class="mt-2">
509
+ <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
510
+ </div>
511
+ </div>
512
+
513
+ <!-- Failed -->
514
+ <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">
515
+ <div class="flex items-center gap-2">
516
+ <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
517
+ <p class="text-sm text-red-700 dark:text-red-300">
518
+ {{ activeTask.error || 'Ingestion failed.' }}
519
+ </p>
520
+ </div>
521
+ <div class="mt-2 flex gap-2">
522
+ <UButton label="Retry" variant="outline" size="xs" icon="i-lucide-refresh-cw" @click="retryIngest" />
523
+ <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
524
+ </div>
525
+ </div>
526
+ </div>
527
+
528
+ <!-- Jobs Queue Table -->
529
+ <div v-if="jobs.length" class="mt-4">
530
+ <div class="flex items-center justify-between mb-3">
531
+ <h3 class="text-sm font-semibold text-muted uppercase tracking-wider">Job Queue</h3>
532
+ <div class="flex items-center gap-3 text-xs text-muted">
533
+ <span v-if="jobsSummary.active">{{ jobsSummary.active }} active</span>
534
+ <span>{{ jobsSummary.completed ?? 0 }} completed</span>
535
+ <span v-if="jobsSummary.total_chunks">{{ jobsSummary.total_chunks }} total chunks</span>
536
+ </div>
537
+ </div>
538
+
539
+ <div class="rounded-lg border border-default overflow-hidden">
540
+ <table class="w-full text-sm">
541
+ <thead>
542
+ <tr class="border-b border-default bg-elevated/30">
543
+ <th class="text-left py-2.5 px-4 text-xs font-semibold text-muted">Source</th>
544
+ <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-20">Type</th>
545
+ <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-40">Status</th>
546
+ <th class="text-right py-2.5 px-3 text-xs font-semibold text-muted w-20">Chunks</th>
547
+ <th class="text-right py-2.5 px-4 text-xs font-semibold text-muted w-32">Time</th>
548
+ </tr>
549
+ </thead>
550
+ <tbody>
551
+ <tr
552
+ v-for="job in jobs"
553
+ :key="job.id"
554
+ class="border-b border-default last:border-b-0 hover:bg-elevated/20 transition-colors"
555
+ >
556
+ <td class="py-2.5 px-4">
557
+ <div class="flex items-center gap-2 min-w-0">
558
+ <UIcon :name="typeIconMap[job.type] ?? 'i-lucide-file'" class="size-4 shrink-0 text-muted" />
559
+ <span class="truncate text-highlighted">{{ job.title }}</span>
560
+ </div>
561
+ </td>
562
+ <td class="py-2.5 px-3">
563
+ <UBadge
564
+ v-if="job.type"
565
+ :label="job.type"
566
+ :color="typeColorMap[job.type] ?? 'neutral'"
567
+ variant="subtle"
568
+ size="xs"
569
+ />
570
+ </td>
571
+ <td class="py-2.5 px-3">
572
+ <div class="flex items-center gap-2">
573
+ <UIcon
574
+ v-if="['queued','processing','downloading','transcribing','embedding'].includes(job.status)"
575
+ name="i-lucide-loader-2"
576
+ class="size-3.5 animate-spin text-primary shrink-0"
577
+ />
578
+ <UIcon v-else-if="job.status === 'completed'" name="i-lucide-check-circle" class="size-3.5 text-green-500 shrink-0" />
579
+ <UIcon v-else-if="job.status === 'failed'" name="i-lucide-x-circle" class="size-3.5 text-red-500 shrink-0" />
580
+ <div class="flex-1 min-w-0">
581
+ <div v-if="['processing','downloading','transcribing','embedding'].includes(job.status)" class="space-y-1">
582
+ <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
583
+ <div class="h-1.5 rounded-full bg-primary transition-all" :style="{ width: `${job.progress}%` }" />
584
+ </div>
585
+ <p class="text-[10px] text-muted truncate">{{ job.message }}</p>
586
+ </div>
587
+ <span v-else class="text-xs" :class="job.status === 'completed' ? 'text-green-400' : job.status === 'failed' ? 'text-red-400' : 'text-muted'">
588
+ {{ job.status }}
589
+ </span>
590
+ </div>
591
+ </div>
592
+ </td>
593
+ <td class="py-2.5 px-3 text-right">
594
+ <span v-if="job.chunks_created" class="text-xs font-mono">{{ job.chunks_created }}</span>
595
+ <span v-else class="text-xs text-muted">—</span>
596
+ </td>
597
+ <td class="py-2.5 px-4 text-right text-xs text-muted">
598
+ {{ formatDate(job.completed_at || job.created_at) }}
599
+ </td>
600
+ </tr>
601
+ </tbody>
602
+ </table>
603
+ </div>
604
+ </div>
605
+
606
+ <!-- Stats Section -->
607
+ <div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
608
+ <div class="rounded-lg border border-default p-4 text-center">
609
+ <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_chunks ?? 0 }}</p>
610
+ <p class="text-xs text-muted">Total Chunks</p>
611
+ </div>
612
+ <div class="rounded-lg border border-default p-4 text-center">
613
+ <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_files ?? 0 }}</p>
614
+ <p class="text-xs text-muted">Total Files</p>
615
+ </div>
616
+ <div class="rounded-lg border border-default p-4 text-center">
617
+ <UBadge
618
+ :label="stats?.vss_available ? 'Available' : 'Unavailable'"
619
+ :color="stats?.vss_available ? 'success' : 'neutral'"
620
+ variant="subtle"
621
+ size="sm"
622
+ />
623
+ <p class="text-xs text-muted mt-1">Vector Search</p>
624
+ </div>
625
+ </div>
626
+
627
+ <!-- Not Indexed State -->
628
+ <div v-if="!isIndexed" class="mt-8">
629
+ <div class="rounded-lg border-2 border-dashed border-default p-8 text-center">
630
+ <UIcon name="i-lucide-database" class="size-16 text-muted mx-auto" />
631
+ <h3 class="mt-4 text-lg font-semibold text-highlighted">Knowledge base not indexed yet</h3>
632
+ <p class="mt-2 text-sm text-muted max-w-lg mx-auto">
633
+ Index your Obsidian vault to enable semantic search across your entire knowledge base.
634
+ </p>
635
+ <div class="mt-6 inline-block rounded-lg border border-default bg-elevated/50 px-6 py-4 text-left">
636
+ <p class="text-xs text-muted mb-2">Run this command to index:</p>
637
+ <code class="font-mono text-sm text-primary">npx arkaos index</code>
638
+ </div>
639
+ <p class="mt-4 text-xs text-muted max-w-md mx-auto">
640
+ This indexes your markdown files into a local vector database for automatic context retrieval.
641
+ The process runs locally and your data never leaves your machine.
642
+ </p>
643
+ </div>
644
+ </div>
645
+
646
+ <!-- Indexed State -->
647
+ <template v-else>
648
+ <!-- Knowledge Areas -->
649
+ <div v-if="stats?.areas?.length" class="mt-6">
650
+ <h3 class="mb-4 text-lg font-semibold text-highlighted">Knowledge Areas</h3>
651
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
652
+ <div
653
+ v-for="area in stats.areas"
654
+ :key="area.name"
655
+ class="rounded-lg border border-default p-4"
656
+ >
657
+ <h4 class="font-medium text-highlighted">{{ area.name }}</h4>
658
+ <div class="mt-2 flex gap-4 text-xs text-muted">
659
+ <span>{{ area.chunks }} chunks</span>
660
+ <span>{{ area.files }} files</span>
661
+ </div>
662
+ </div>
663
+ </div>
664
+ </div>
665
+
666
+ <!-- Search -->
667
+ <div class="mt-6 rounded-lg border border-default p-6">
668
+ <h3 class="mb-4 text-lg font-semibold text-highlighted">Search Knowledge</h3>
669
+ <form class="flex gap-2" @submit.prevent="handleSearch">
670
+ <UInput
671
+ v-model="searchQuery"
672
+ class="flex-1"
673
+ icon="i-lucide-search"
674
+ placeholder="Search the knowledge base..."
675
+ aria-label="Search knowledge base"
676
+ />
677
+ <UButton
678
+ type="submit"
679
+ label="Search"
680
+ :loading="searching"
681
+ icon="i-lucide-search"
682
+ />
683
+ </form>
684
+
685
+ <!-- Search Results -->
686
+ <div v-if="searching" class="mt-4 flex items-center justify-center py-8">
687
+ <UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-muted" />
688
+ </div>
689
+
690
+ <template v-else-if="hasSearched">
691
+ <div v-if="searchResults.length" class="mt-4 space-y-3">
692
+ <p class="text-xs text-muted">{{ searchTotal }} result{{ searchTotal !== 1 ? 's' : '' }} found</p>
693
+ <div
694
+ v-for="(result, idx) in searchResults"
695
+ :key="result.id ?? idx"
696
+ class="rounded-lg border border-default p-4"
697
+ >
698
+ <div class="mb-2 flex items-center justify-between gap-2">
699
+ <div class="flex items-center gap-2 min-w-0">
700
+ <UBadge v-if="result.area" :label="result.area" variant="subtle" size="sm" />
701
+ <span v-if="result.heading" class="text-sm font-medium text-highlighted truncate">
702
+ {{ result.heading }}
703
+ </span>
704
+ </div>
705
+ <span class="text-xs text-muted whitespace-nowrap">
706
+ Score: {{ formatScore(result.score) }}
707
+ </span>
708
+ </div>
709
+ <p v-if="result.source" class="text-xs text-muted mb-1 truncate">
710
+ <UIcon name="i-lucide-file-text" class="size-3 inline-block mr-1" />
711
+ {{ result.source }}
712
+ </p>
713
+ <p class="text-sm text-muted line-clamp-3">
714
+ {{ result.text || result.content }}
715
+ </p>
716
+ </div>
717
+ </div>
718
+
719
+ <div v-else class="mt-4 text-center text-sm text-muted py-6">
720
+ <UIcon name="i-lucide-search-x" class="size-8 text-muted mx-auto mb-2" />
721
+ <p>No results found for "{{ searchQuery }}".</p>
722
+ </div>
723
+ </template>
724
+ </div>
725
+ </template>
726
+ </template>
727
+ </template>
728
+ </UDashboardPanel>
729
+ </template>